minimal-agent 0.18.10 → 0.18.12
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/advisor/advisorCoordinator.js +30 -6
- package/src/advisor/advisorCoordinator.ts +23 -6
- package/src/advisor/deliveryReview.js +23 -0
- package/src/advisor/deliveryReview.ts +22 -0
- package/src/advisor/presentation.js +3 -1
- package/src/advisor/presentation.ts +4 -1
- package/src/loop/README.md +44 -0
- package/src/loop/deliveryIntent.js +22 -0
- package/src/loop/deliveryIntent.ts +39 -0
- package/src/loop/messages.js +146 -0
- package/src/loop/messages.ts +166 -0
- package/src/loop/options.js +1 -0
- package/src/loop/options.ts +155 -0
- package/src/loop/progressStream.js +40 -0
- package/src/loop/progressStream.ts +44 -0
- package/src/loop/publication.js +94 -0
- package/src/loop/publication.ts +124 -0
- package/src/loop/requestPreparation.js +180 -0
- package/src/loop/requestPreparation.ts +226 -0
- package/src/loop/toolBatch.js +99 -0
- package/src/loop/toolBatch.ts +123 -0
- package/src/loop/toolExecutor.js +304 -0
- package/src/loop/toolExecutor.ts +363 -0
- package/src/loop/toolPresentation.js +135 -0
- package/src/loop/toolPresentation.ts +134 -0
- package/src/loop/toolSemantics.js +45 -0
- package/src/loop/toolSemantics.ts +51 -0
- package/src/loop.js +369 -1114
- package/src/loop.ts +480 -1380
- package/src/tools/advisor/advisor.js +5 -2
- package/src/tools/advisor/advisor.ts +6 -2
- package/src/tools/bash/semantics.js +33 -1
- package/src/tools/bash/semantics.ts +27 -1
- package/src/tools/toolPromptGuides.js +1 -1
- package/src/tools/toolPromptGuides.ts +1 -1
- package/src/tools/webfetch/pageSnapshot.js +56 -0
- package/src/tools/webfetch/pageSnapshot.ts +51 -0
- package/src/tools/webfetch/webfetch.js +77 -116
- package/src/tools/webfetch/webfetch.ts +70 -136
- package/src/types.ts +8 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { buildUserContent } from '../multimodal.js';
|
|
3
|
+
import type { Message, ToolCall, ToolResult, Provider } from '../types.js';
|
|
4
|
+
// ---------------- 辅助函数 ----------------
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 易变上下文只存在于 provider 请求副本中,不会进入会话历史。
|
|
8
|
+
*/
|
|
9
|
+
export const SYSTEM_REMINDER_OPEN = '<system-reminder>';
|
|
10
|
+
export const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Attach transient context to the current turn's user message in a model-only
|
|
14
|
+
* clone. Later tool-loop requests reuse the same decorated message, making each
|
|
15
|
+
* serialized request a strict prefix extension of the previous one.
|
|
16
|
+
*/
|
|
17
|
+
export function buildModelMessages(
|
|
18
|
+
history: Message[],
|
|
19
|
+
transientContext?: string,
|
|
20
|
+
currentUserMessageId?: string,
|
|
21
|
+
): Message[] {
|
|
22
|
+
history = history.filter((message) => !(message.role === 'system' && message._ephemeral));
|
|
23
|
+
const transient = transientContext?.trim();
|
|
24
|
+
if (!transient) return history;
|
|
25
|
+
let index = currentUserMessageId
|
|
26
|
+
? history.findIndex((message) => message.role === 'user' && message.id === currentUserMessageId)
|
|
27
|
+
: -1;
|
|
28
|
+
if (index < 0) {
|
|
29
|
+
for (let cursor = history.length - 1; cursor >= 0; cursor--) {
|
|
30
|
+
if (history[cursor]?.role === 'user') {
|
|
31
|
+
index = cursor;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (index < 0) return history;
|
|
37
|
+
const target = history[index];
|
|
38
|
+
if (target.role !== 'user') return history;
|
|
39
|
+
const reminder = `${SYSTEM_REMINDER_OPEN}\n${transient}\n${SYSTEM_REMINDER_CLOSE}`;
|
|
40
|
+
const content =
|
|
41
|
+
typeof target.content === 'string'
|
|
42
|
+
? `${target.content}\n\n${reminder}`
|
|
43
|
+
: [...target.content, { type: 'text' as const, text: reminder }];
|
|
44
|
+
const next = [...history];
|
|
45
|
+
next[index] = { ...target, content };
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 关键节点图像核验的合成消息标记:既是给模型的提示前缀,也是 evictPriorVisionShots 识别旧图消息的锚。
|
|
50
|
+
export const VISION_SHOT_MARKER = '【实况图像】';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 历史标记。这套机制最初只有 WebBrowser 用得上,文案写死了「浏览器」;
|
|
54
|
+
* 现在 Bash 的 ATTACH_IMAGE sentinel 也能产图(屏幕截图 / 生成的产物),文案必须中性。
|
|
55
|
+
* 但**持久化会话里仍存着旧前缀的消息**,淘汰逻辑必须继续认它 ——
|
|
56
|
+
* 否则老会话里的旧截图永远不会被降级,图片一路累积撑爆上下文。
|
|
57
|
+
*/
|
|
58
|
+
const LEGACY_VISION_SHOT_MARKERS = ['【浏览器实况截图】'] as const;
|
|
59
|
+
|
|
60
|
+
const VISION_SHOT_PROMPT = `${VISION_SHOT_MARKER}上面是刚才工具执行后附带的真实图像(浏览器页面 / 屏幕截图 / 生成的产物)。请仔细看图核对:是否真的到达/呈现了你预期的状态?若与预期不符(空白/报错/弹窗遮挡/验证拦截/内容不对),不要假设成功,如实说明并调整下一步。`;
|
|
61
|
+
|
|
62
|
+
/** 一条 user 消息的文本是否是「实况图像」合成消息(含历史前缀)。 */
|
|
63
|
+
function isVisionShotText(text: string): boolean {
|
|
64
|
+
return text.startsWith(VISION_SHOT_MARKER) || LEGACY_VISION_SHOT_MARKERS.some((marker) => text.startsWith(marker));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把本轮工具附带的实况截图(ToolResult.visionImages)组装成一条带图 user 消息喂给模型自我核验。
|
|
69
|
+
* - 仅在模型明确支持识图时注入(provider.supportsVision === true);否则丢弃(不假装核验、也不给非视觉端点发图触发 400)。
|
|
70
|
+
* - 注入前先把历史里旧的截图消息降级为纯文字占位(evictPriorVisionShots),保证上下文里最多一张实况图,
|
|
71
|
+
* 避免图片累积撑爆上下文(呼应浏览器 ADR「高频帧只存内存、只持久化最终结果」)。
|
|
72
|
+
* - **失败的工具结果同样回填**:出错现场那张图往往比成功时更有信息量(权限弹窗、
|
|
73
|
+
* 验证拦截、意外的前台窗口)。此前这里要求 `ok === true`,逼得想附图的工具只能
|
|
74
|
+
* 把失败伪装成成功——见 types.ts 里 ToolResult 那段注释。
|
|
75
|
+
*/
|
|
76
|
+
export async function injectVisionFollowups(
|
|
77
|
+
history: Message[],
|
|
78
|
+
settled: { result: ToolResult }[],
|
|
79
|
+
provider: Provider,
|
|
80
|
+
): Promise<Message | null> {
|
|
81
|
+
if (provider.supportsVision === false) return null;
|
|
82
|
+
const images = settled.flatMap((s) => s.result.visionImages ?? []);
|
|
83
|
+
if (images.length === 0) return null;
|
|
84
|
+
evictPriorVisionShots(history);
|
|
85
|
+
// 工具可以给自己这批图配一句说明(visionNote)。内核只负责把它拼在通用提示之后,
|
|
86
|
+
// 不解读内容——「这是一组 1Hz 连续帧」这类语义只有产图的工具知道。
|
|
87
|
+
const notes = settled
|
|
88
|
+
.flatMap((s) => (s.result.visionNote ? [s.result.visionNote] : []))
|
|
89
|
+
.filter((note, index, all) => all.indexOf(note) === index);
|
|
90
|
+
const text = notes.length > 0 ? `${VISION_SHOT_PROMPT}\n\n${notes.join('\n\n')}` : VISION_SHOT_PROMPT;
|
|
91
|
+
const content = await buildUserContent({ text, images });
|
|
92
|
+
const msg: Message = {
|
|
93
|
+
role: 'user',
|
|
94
|
+
content,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
id: crypto.randomUUID(),
|
|
97
|
+
};
|
|
98
|
+
history.push(msg);
|
|
99
|
+
return msg;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 把历史里旧的实况图像 user 消息降级为纯文字占位、剔除其图片,释放上下文(只保留最近一张真图)。 */
|
|
103
|
+
export function evictPriorVisionShots(history: Message[]): void {
|
|
104
|
+
for (const msg of history) {
|
|
105
|
+
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
|
|
106
|
+
const textPart = msg.content.find((p) => p.type === 'text');
|
|
107
|
+
if (textPart?.type === 'text' && isVisionShotText(textPart.text)) {
|
|
108
|
+
msg.content = `${VISION_SHOT_MARKER}(此前的实况图像已从上下文移除以节省空间。)`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 用户按ESC手动中断
|
|
115
|
+
*/
|
|
116
|
+
export function pushManualInterruptMarker(history: Message[]): void {
|
|
117
|
+
history.push({
|
|
118
|
+
role: 'user',
|
|
119
|
+
content: '(用户按下了 ESC/Ctrl+C 中断了任务。等待用户新指令。)',
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
id: crypto.randomUUID(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 有新的用户输入在队列中,当前安全停靠
|
|
127
|
+
*/
|
|
128
|
+
export function pushQueuedInputMarker(history: Message[]): void {
|
|
129
|
+
history.push({
|
|
130
|
+
role: 'user',
|
|
131
|
+
content: [
|
|
132
|
+
'(系统提示:用户在工作过程中发送了新的消息。',
|
|
133
|
+
'你现在看到的是上一轮工作在被打断前已完成的部分。',
|
|
134
|
+
'请阅读用户的新消息,判断:',
|
|
135
|
+
'1. 如果是不相关的新任务:简要确认已收到,转向新任务,可在新任务完成后询问是否回到之前工作',
|
|
136
|
+
'2. 如果是补充信息或前置依赖:把新信息纳入考虑,判断是否需要调整已做的工作,然后继续',
|
|
137
|
+
'3. 如果是改变方向或取消:评估已完成的部分,说明你对变更的理解,按新方向推进)',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
id: crypto.randomUUID(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 把一条 tool_call_delta 合并到累积数组上 */
|
|
145
|
+
export function mergeToolCallDelta(
|
|
146
|
+
acc: ToolCall[],
|
|
147
|
+
ev: {
|
|
148
|
+
index: number;
|
|
149
|
+
id?: string;
|
|
150
|
+
name?: string;
|
|
151
|
+
argumentsDelta?: string;
|
|
152
|
+
},
|
|
153
|
+
): void {
|
|
154
|
+
let slot = acc[ev.index];
|
|
155
|
+
if (!slot) {
|
|
156
|
+
slot = {
|
|
157
|
+
id: '',
|
|
158
|
+
type: 'function',
|
|
159
|
+
function: { name: '', arguments: '' },
|
|
160
|
+
};
|
|
161
|
+
acc[ev.index] = slot;
|
|
162
|
+
}
|
|
163
|
+
if (ev.id) slot.id = ev.id;
|
|
164
|
+
if (ev.name) slot.function.name += ev.name;
|
|
165
|
+
if (ev.argumentsDelta) slot.function.arguments += ev.argumentsDelta;
|
|
166
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ContextBundle } from '../context/contextBundle.js';
|
|
2
|
+
import type { chat as defaultChat } from '../llm/client.js';
|
|
3
|
+
import type { AgentRuntimeContext } from '../runtime/agentRuntimeContext.js';
|
|
4
|
+
import type { InvocationPolicy } from '../runtime/invocationPolicy.js';
|
|
5
|
+
import type { SessionState } from '../session/sessionState.js';
|
|
6
|
+
import type { ToolGate } from '../toolGate.js';
|
|
7
|
+
import type {
|
|
8
|
+
DeliverableManifest,
|
|
9
|
+
ImageDetail,
|
|
10
|
+
ImageInputSpec,
|
|
11
|
+
Message,
|
|
12
|
+
Provider,
|
|
13
|
+
Tool,
|
|
14
|
+
ToolCallContext,
|
|
15
|
+
ToolResult,
|
|
16
|
+
} from '../types.js';
|
|
17
|
+
export interface RunQueryOptions {
|
|
18
|
+
agentHandoff?: ToolCallContext['agentHandoff'];
|
|
19
|
+
/** Generic embedded-task policy; omitted for ordinary conversation turns. */
|
|
20
|
+
invocationPolicy?: InvocationPolicy;
|
|
21
|
+
executionServices?: ToolCallContext['executionServices'];
|
|
22
|
+
provider: Provider;
|
|
23
|
+
/** Canonical host-issued trust context. Legacy fields below remain adapters. */
|
|
24
|
+
runtimeContext?: AgentRuntimeContext;
|
|
25
|
+
/** 历史会被 mutate(追加新消息、压缩时整体替换内容);调用方持同一引用 */
|
|
26
|
+
history: Message[];
|
|
27
|
+
/** Host mailbox: consume at model boundaries, never mutate an in-flight request. */
|
|
28
|
+
takePendingMessages?: () => Message[];
|
|
29
|
+
/** 最多多少轮(防止失控调工具) */
|
|
30
|
+
maxTurns?: number;
|
|
31
|
+
/**
|
|
32
|
+
* 工作流 agent/llm 步的工具白名单:只把这些工具下发给模型并允许执行。
|
|
33
|
+
* 缺省 = 当前宿主工具全集;空数组 = 无工具。
|
|
34
|
+
*/
|
|
35
|
+
allowedTools?: string[];
|
|
36
|
+
/** 调用方工具边界名称,仅用于准确解释越界调用;缺省表示宿主能力边界。 */
|
|
37
|
+
toolBoundaryLabel?: string;
|
|
38
|
+
/** Disable Advisor checkpoints for an explicitly read-only embedded node. */
|
|
39
|
+
advisorCheckpoints?: boolean;
|
|
40
|
+
/** Tools the host/workflow explicitly requires; emergency schema reduction preserves them. */
|
|
41
|
+
requiredTools?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* provider 原生结构化输出(`response_format.json_schema`),原样透传给 `chat()`。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ **只在本轮没有工具时才该传**:response_format 会强制**每一条** assistant
|
|
46
|
+
* 消息都是 JSON,而工具调用轮的 assistant 消息带的是 tool_calls —— 两者互斥,
|
|
47
|
+
* 同时给会让带工具的循环直接崩。工作流引擎因此只在 `llm()`(零工具)和
|
|
48
|
+
* schema 修复轮(零工具)上传它;`agent()` 的结构化输出走「跑完再修复」那条路。
|
|
49
|
+
*
|
|
50
|
+
* Anthropic 格式的 provider 不支持,`chat()` 会在发请求前抛错 —— 所以它是
|
|
51
|
+
* **加成**而不是地基,地基是引擎侧的「校验 + 有界修复」。
|
|
52
|
+
*/
|
|
53
|
+
responseFormat?: {
|
|
54
|
+
type: 'json_schema';
|
|
55
|
+
json_schema: {
|
|
56
|
+
name: string;
|
|
57
|
+
schema: Record<string, unknown>;
|
|
58
|
+
strict?: boolean;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Host-provided tool catalog. Production hosts normally omit this and use ALL_TOOLS;
|
|
63
|
+
* tests and embedders with custom executors must provide matching schemas so the
|
|
64
|
+
* model-visible catalog and execution boundary remain identical.
|
|
65
|
+
*/
|
|
66
|
+
availableTools?: readonly Tool[];
|
|
67
|
+
/** 用户取消 */
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
/**
|
|
70
|
+
* 回调:宿主检查是否有排队输入需要在安全点处理。
|
|
71
|
+
* loop只在安全检查点调用:每轮while开始时、工具全部执行完成后。
|
|
72
|
+
* 返回true表示应该结束当前turn(自然return,不抛异常)。
|
|
73
|
+
*/
|
|
74
|
+
hasQueuedInput?: () => boolean;
|
|
75
|
+
/** Concurrent-safe tool throughput; defaults to the safe segment size, host may tune upward. */
|
|
76
|
+
toolConcurrency?: number;
|
|
77
|
+
/**
|
|
78
|
+
* 回调:工具开始/结束执行,用于宿主跟踪运行中工具状态。
|
|
79
|
+
*/
|
|
80
|
+
onToolStart?: (toolName: string, toolCallId: string) => void;
|
|
81
|
+
onToolEnd?: (toolCallId: string, wasAborted: boolean) => void;
|
|
82
|
+
/**
|
|
83
|
+
* 多租户场景下注入隔离的 session 级状态;缺省走模块单例。
|
|
84
|
+
* TUI / print(单进程单 session)不传即可;HTTP server per-request 必须传。
|
|
85
|
+
*/
|
|
86
|
+
sessionState?: SessionState;
|
|
87
|
+
/**
|
|
88
|
+
* 注入 chat 实现(仅测试用)。
|
|
89
|
+
*
|
|
90
|
+
* 之前用 bun:test 的 mock.module 替换 llm/client.js 会进程全局污染所有跨文件
|
|
91
|
+
* import;改用显式 DI 后,测试传 stub 进来即可,零污染。生产代码不需要传。
|
|
92
|
+
*/
|
|
93
|
+
chat?: typeof defaultChat;
|
|
94
|
+
/**
|
|
95
|
+
* 注入 executeTool 实现(仅测试用)。同上,DI 而非 mock.module,零污染。
|
|
96
|
+
*/
|
|
97
|
+
executeTool?: (name: string, rawArgsJson: string, signal?: AbortSignal, ctx?: ToolCallContext) => Promise<ToolResult>;
|
|
98
|
+
/**
|
|
99
|
+
* Trusted host persistence boundary for formal results. It runs after
|
|
100
|
+
* PresentResult validates the files but before tool_end is published or the
|
|
101
|
+
* result is appended to history. Throwing turns the tool call into a failure,
|
|
102
|
+
* so a UI can never display an uncommitted delivery as durable.
|
|
103
|
+
*/
|
|
104
|
+
commitFormalDelivery?: (input: {
|
|
105
|
+
toolCallId: string;
|
|
106
|
+
turnId: string;
|
|
107
|
+
manifest: DeliverableManifest;
|
|
108
|
+
}) => Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* 工具执行闸门(通用机制,见 src/toolGate.ts):loop 在执行每个工具前
|
|
111
|
+
* await 它拿放行/拒绝;拒绝时把 reason 当 tool_result 回填。内核零策略——审批语义
|
|
112
|
+
* 全在宿主(如 gateway 跨机指令同意闸门)。缺省不传 = 全放行,行为不变。
|
|
113
|
+
*/
|
|
114
|
+
toolGate?: ToolGate;
|
|
115
|
+
/**
|
|
116
|
+
* 仅对本次回合可见的系统上下文。它会随每次模型调用注入,但不会写入 history、
|
|
117
|
+
* 会话文件或压缩摘要,适合项目事实、远程委托和附件概要等外部上下文。
|
|
118
|
+
*
|
|
119
|
+
* 注入到本轮用户消息的模型副本,不改变 UI 或持久化历史。
|
|
120
|
+
*/
|
|
121
|
+
transientContext?: string;
|
|
122
|
+
/** Structured transient fragments allow deterministic request-time degradation. */
|
|
123
|
+
transientBundle?: ContextBundle;
|
|
124
|
+
/** Provider cache routing for this conversation; never persisted. */
|
|
125
|
+
promptCache?: import('../types.js').PromptCacheContext;
|
|
126
|
+
/** 仅供宿主 UI 恢复附件卡片;不会发送给 LLM。 */
|
|
127
|
+
userAttachments?: import('../types.js').MessageAttachmentRef[];
|
|
128
|
+
/** Host-authorized files for typed workflow inputs; runtime-only paths. */
|
|
129
|
+
inputResources?: readonly import('../types.js').InvocationResource[];
|
|
130
|
+
/** Stable host-provided identity for the user message of this turn. */
|
|
131
|
+
userMessageId?: string;
|
|
132
|
+
/** Trusted local origin for every message created by this run. */
|
|
133
|
+
messageProvenance?: import('../types.js').ConversationProvenance;
|
|
134
|
+
/** Browser element chips are persisted for UI but stripped before provider calls. */
|
|
135
|
+
userBrowserReferences?: import('../types.js').MessageBrowserReference[];
|
|
136
|
+
/** 显式传入的图片附件(-p --image / stream-json images)。 */
|
|
137
|
+
imageInputs?: ImageInputSpec[];
|
|
138
|
+
/** 是否解析用户文本里的 @./image.png / 图片 URL。TUI 默认开启。 */
|
|
139
|
+
parseInlineImages?: boolean;
|
|
140
|
+
/** 图片 detail 默认值:low/default/high。 */
|
|
141
|
+
imageDetail?: ImageDetail;
|
|
142
|
+
/** OpenAI 兼容扩展:图片最长边像素。 */
|
|
143
|
+
imageMaxLongSidePixel?: number;
|
|
144
|
+
/** Host-provided browser ownership scope; never exposed to the model. */
|
|
145
|
+
browserRun?: ToolCallContext['browserRun'];
|
|
146
|
+
/** Host-provided native desktop-control scope; absent means ComputerUse is not exposed. */
|
|
147
|
+
computerRun?: ToolCallContext['computerRun'];
|
|
148
|
+
/** Trusted host-only human handoff bridge for native Computer Use. */
|
|
149
|
+
computerIntervention?: ToolCallContext['computerIntervention'];
|
|
150
|
+
memoryContext?: {
|
|
151
|
+
access: import('../runtime/agentRuntimeContext.js').MemoryAccessInput;
|
|
152
|
+
workspaceId?: string;
|
|
153
|
+
sessionId?: string;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Bridge callback progress to a generator, draining events before returning or throwing. */
|
|
2
|
+
export async function* progressStream(run) {
|
|
3
|
+
const queue = [];
|
|
4
|
+
let wake;
|
|
5
|
+
let finished = false;
|
|
6
|
+
const notify = () => {
|
|
7
|
+
wake?.();
|
|
8
|
+
wake = undefined;
|
|
9
|
+
};
|
|
10
|
+
const result = Promise.resolve().then(() => run((event) => {
|
|
11
|
+
queue.push(event);
|
|
12
|
+
notify();
|
|
13
|
+
}));
|
|
14
|
+
const settled = result
|
|
15
|
+
.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }))
|
|
16
|
+
.then((outcome) => {
|
|
17
|
+
finished = true;
|
|
18
|
+
notify();
|
|
19
|
+
return outcome;
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
while (!finished || queue.length) {
|
|
23
|
+
while (queue.length)
|
|
24
|
+
yield queue.shift();
|
|
25
|
+
if (!finished)
|
|
26
|
+
await new Promise((resolve) => {
|
|
27
|
+
wake = resolve;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const outcome = await settled;
|
|
31
|
+
if (!outcome.ok)
|
|
32
|
+
throw outcome.error;
|
|
33
|
+
return outcome.value;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
// Generator disposal must not orphan a still-running tool. Cancellation is
|
|
37
|
+
// owned by the caller's AbortSignal; this boundary only drains settlement.
|
|
38
|
+
await settled;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Bridge callback progress to a generator, draining events before returning or throwing. */
|
|
2
|
+
export async function* progressStream<Event, Result>(
|
|
3
|
+
run: (emit: (event: Event) => void) => Promise<Result>,
|
|
4
|
+
): AsyncGenerator<Event, Result> {
|
|
5
|
+
const queue: Event[] = [];
|
|
6
|
+
let wake: (() => void) | undefined;
|
|
7
|
+
let finished = false;
|
|
8
|
+
const notify = () => {
|
|
9
|
+
wake?.();
|
|
10
|
+
wake = undefined;
|
|
11
|
+
};
|
|
12
|
+
const result = Promise.resolve().then(() =>
|
|
13
|
+
run((event) => {
|
|
14
|
+
queue.push(event);
|
|
15
|
+
notify();
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
const settled = result
|
|
19
|
+
.then(
|
|
20
|
+
(value) => ({ ok: true as const, value }),
|
|
21
|
+
(error) => ({ ok: false as const, error }),
|
|
22
|
+
)
|
|
23
|
+
.then((outcome) => {
|
|
24
|
+
finished = true;
|
|
25
|
+
notify();
|
|
26
|
+
return outcome;
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
while (!finished || queue.length) {
|
|
30
|
+
while (queue.length) yield queue.shift()!;
|
|
31
|
+
if (!finished)
|
|
32
|
+
await new Promise<void>((resolve) => {
|
|
33
|
+
wake = resolve;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const outcome = await settled;
|
|
37
|
+
if (!outcome.ok) throw outcome.error;
|
|
38
|
+
return outcome.value;
|
|
39
|
+
} finally {
|
|
40
|
+
// Generator disposal must not orphan a still-running tool. Cancellation is
|
|
41
|
+
// owned by the caller's AbortSignal; this boundary only drains settlement.
|
|
42
|
+
await settled;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { buildContextBudgetPlan } from '../context/budgetPlan.js';
|
|
2
|
+
import { finalAnswerMessages } from '../runtime/finalAnswer.js';
|
|
3
|
+
/** A one-way visibility boundary: never retract or regenerate visible text. */
|
|
4
|
+
export async function* publishFinalAnswer(input) {
|
|
5
|
+
const { provider, messages, candidate, candidateFinishReason, output, chatFn, options, signal, conversationMode, turnUsage, } = input;
|
|
6
|
+
let lastFinishReason = candidateFinishReason;
|
|
7
|
+
let publicationError;
|
|
8
|
+
let publicationInterrupted = false;
|
|
9
|
+
let streamed = false;
|
|
10
|
+
let publishedText = '';
|
|
11
|
+
let publicationDone = false;
|
|
12
|
+
yield { type: 'stage_change', stage: 'streaming' };
|
|
13
|
+
try {
|
|
14
|
+
const publicationMessages = finalAnswerMessages(messages, candidate, output);
|
|
15
|
+
const publicationPlan = buildContextBudgetPlan({
|
|
16
|
+
provider,
|
|
17
|
+
history: publicationMessages,
|
|
18
|
+
tools: [],
|
|
19
|
+
});
|
|
20
|
+
if (publicationPlan.overBudget)
|
|
21
|
+
throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
|
|
22
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
23
|
+
publicationInterrupted = true;
|
|
24
|
+
throw new Error('最终发布已取消。');
|
|
25
|
+
}
|
|
26
|
+
turnUsage.totalCalls += 1;
|
|
27
|
+
for await (const event of chatFn({
|
|
28
|
+
provider,
|
|
29
|
+
messages: publicationMessages,
|
|
30
|
+
tools: [],
|
|
31
|
+
signal,
|
|
32
|
+
conversationMode: conversationMode,
|
|
33
|
+
promptCache: options.promptCache,
|
|
34
|
+
})) {
|
|
35
|
+
if (event.type === 'done' && !publicationDone) {
|
|
36
|
+
publicationDone = true;
|
|
37
|
+
if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
|
|
38
|
+
turnUsage.reportedCalls += 1;
|
|
39
|
+
turnUsage.inputTokens += event.promptTokens ?? 0;
|
|
40
|
+
turnUsage.outputTokens += event.outputTokens ?? 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
44
|
+
publicationInterrupted = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (event.type === 'text_delta') {
|
|
48
|
+
// Final expression is live generation, not an exact-copy replay.
|
|
49
|
+
// Once visible, text is never replaced or joined with a different draft.
|
|
50
|
+
publishedText += event.delta;
|
|
51
|
+
streamed = true;
|
|
52
|
+
yield { type: 'text', delta: event.delta };
|
|
53
|
+
}
|
|
54
|
+
else if (event.type === 'tool_call_delta') {
|
|
55
|
+
throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
|
|
56
|
+
}
|
|
57
|
+
else if (event.type === 'done') {
|
|
58
|
+
publicationDone = true;
|
|
59
|
+
lastFinishReason = event.stopReason;
|
|
60
|
+
if (event.stopReason === 'aborted')
|
|
61
|
+
publicationInterrupted = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!publicationDone && !publicationInterrupted)
|
|
65
|
+
throw new Error('最终答案流未正常结束。');
|
|
66
|
+
if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length')
|
|
67
|
+
throw new Error('最终答案流缺少有效的结束状态。');
|
|
68
|
+
if (!publishedText.trim() && !publicationInterrupted)
|
|
69
|
+
throw new Error('最终表达未返回正文。');
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
publicationInterrupted ||=
|
|
73
|
+
signal?.aborted === true || options.hasQueuedInput?.() === true || error?.name === 'AbortError';
|
|
74
|
+
publicationError = error;
|
|
75
|
+
}
|
|
76
|
+
if (publicationError && !publicationInterrupted && !publishedText) {
|
|
77
|
+
yield {
|
|
78
|
+
type: 'warning',
|
|
79
|
+
message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。',
|
|
80
|
+
};
|
|
81
|
+
yield { type: 'text', delta: candidate };
|
|
82
|
+
streamed = true;
|
|
83
|
+
publishedText = candidate;
|
|
84
|
+
lastFinishReason = candidateFinishReason;
|
|
85
|
+
publicationError = undefined;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
text: publishedText,
|
|
89
|
+
lastFinishReason,
|
|
90
|
+
publicationError,
|
|
91
|
+
publicationInterrupted,
|
|
92
|
+
streamed,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { buildContextBudgetPlan } from '../context/budgetPlan.js';
|
|
2
|
+
import { finalAnswerMessages } from '../runtime/finalAnswer.js';
|
|
3
|
+
import type { OutputContract } from '../runtime/outputContract.js';
|
|
4
|
+
import type { LoopEvent, Message, Provider, TurnResourceUsage } from '../types.js';
|
|
5
|
+
import type { RunQueryOptions } from './options.js';
|
|
6
|
+
|
|
7
|
+
type FinishReason = 'stop' | 'tool_calls' | 'length' | 'unknown' | 'aborted';
|
|
8
|
+
/** A one-way visibility boundary: never retract or regenerate visible text. */
|
|
9
|
+
export async function* publishFinalAnswer(input: {
|
|
10
|
+
provider: Provider;
|
|
11
|
+
messages: Message[];
|
|
12
|
+
candidate: string;
|
|
13
|
+
candidateFinishReason: FinishReason;
|
|
14
|
+
output: OutputContract;
|
|
15
|
+
chatFn: NonNullable<RunQueryOptions['chat']>;
|
|
16
|
+
options: Pick<RunQueryOptions, 'hasQueuedInput' | 'promptCache'>;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
conversationMode: 'private' | 'group';
|
|
19
|
+
turnUsage: Omit<TurnResourceUsage, 'trustworthy'>;
|
|
20
|
+
}): AsyncGenerator<
|
|
21
|
+
LoopEvent,
|
|
22
|
+
{
|
|
23
|
+
text: string;
|
|
24
|
+
lastFinishReason: FinishReason;
|
|
25
|
+
publicationError: unknown;
|
|
26
|
+
publicationInterrupted: boolean;
|
|
27
|
+
streamed: boolean;
|
|
28
|
+
}
|
|
29
|
+
> {
|
|
30
|
+
const {
|
|
31
|
+
provider,
|
|
32
|
+
messages,
|
|
33
|
+
candidate,
|
|
34
|
+
candidateFinishReason,
|
|
35
|
+
output,
|
|
36
|
+
chatFn,
|
|
37
|
+
options,
|
|
38
|
+
signal,
|
|
39
|
+
conversationMode,
|
|
40
|
+
turnUsage,
|
|
41
|
+
} = input;
|
|
42
|
+
let lastFinishReason = candidateFinishReason;
|
|
43
|
+
let publicationError: unknown;
|
|
44
|
+
let publicationInterrupted = false;
|
|
45
|
+
let streamed = false;
|
|
46
|
+
let publishedText = '';
|
|
47
|
+
let publicationDone = false;
|
|
48
|
+
yield { type: 'stage_change', stage: 'streaming' };
|
|
49
|
+
try {
|
|
50
|
+
const publicationMessages = finalAnswerMessages(messages, candidate, output);
|
|
51
|
+
const publicationPlan = buildContextBudgetPlan({
|
|
52
|
+
provider,
|
|
53
|
+
history: publicationMessages,
|
|
54
|
+
tools: [],
|
|
55
|
+
});
|
|
56
|
+
if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
|
|
57
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
58
|
+
publicationInterrupted = true;
|
|
59
|
+
throw new Error('最终发布已取消。');
|
|
60
|
+
}
|
|
61
|
+
turnUsage.totalCalls += 1;
|
|
62
|
+
for await (const event of chatFn({
|
|
63
|
+
provider,
|
|
64
|
+
messages: publicationMessages,
|
|
65
|
+
tools: [],
|
|
66
|
+
signal,
|
|
67
|
+
conversationMode: conversationMode,
|
|
68
|
+
promptCache: options.promptCache,
|
|
69
|
+
})) {
|
|
70
|
+
if (event.type === 'done' && !publicationDone) {
|
|
71
|
+
publicationDone = true;
|
|
72
|
+
if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
|
|
73
|
+
turnUsage.reportedCalls += 1;
|
|
74
|
+
turnUsage.inputTokens += event.promptTokens ?? 0;
|
|
75
|
+
turnUsage.outputTokens += event.outputTokens ?? 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
79
|
+
publicationInterrupted = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (event.type === 'text_delta') {
|
|
83
|
+
// Final expression is live generation, not an exact-copy replay.
|
|
84
|
+
// Once visible, text is never replaced or joined with a different draft.
|
|
85
|
+
publishedText += event.delta;
|
|
86
|
+
streamed = true;
|
|
87
|
+
yield { type: 'text', delta: event.delta };
|
|
88
|
+
} else if (event.type === 'tool_call_delta') {
|
|
89
|
+
throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
|
|
90
|
+
} else if (event.type === 'done') {
|
|
91
|
+
publicationDone = true;
|
|
92
|
+
lastFinishReason = event.stopReason;
|
|
93
|
+
if (event.stopReason === 'aborted') publicationInterrupted = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
|
|
97
|
+
if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length')
|
|
98
|
+
throw new Error('最终答案流缺少有效的结束状态。');
|
|
99
|
+
if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
|
|
100
|
+
} catch (error) {
|
|
101
|
+
publicationInterrupted ||=
|
|
102
|
+
signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
|
|
103
|
+
publicationError = error;
|
|
104
|
+
}
|
|
105
|
+
if (publicationError && !publicationInterrupted && !publishedText) {
|
|
106
|
+
yield {
|
|
107
|
+
type: 'warning',
|
|
108
|
+
message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。',
|
|
109
|
+
};
|
|
110
|
+
yield { type: 'text', delta: candidate };
|
|
111
|
+
streamed = true;
|
|
112
|
+
publishedText = candidate;
|
|
113
|
+
lastFinishReason = candidateFinishReason;
|
|
114
|
+
publicationError = undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
text: publishedText,
|
|
119
|
+
lastFinishReason,
|
|
120
|
+
publicationError,
|
|
121
|
+
publicationInterrupted,
|
|
122
|
+
streamed,
|
|
123
|
+
};
|
|
124
|
+
}
|