@springbrand/agent-runtime 0.2.0-alpha.72 → 0.3.0-alpha.1
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 +3 -3
- package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
- package/src/adapter/cloudflare/agent.ts +1539 -0
- package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
- package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
- package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
- package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
- package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
- package/src/{db → adapter/cloudflare}/schema.ts +16 -83
- package/src/adapter/cloudflare/subagent/definition.ts +63 -33
- package/src/adapter/cloudflare/subagent/runner.ts +174 -120
- package/src/adapter/cloudflare/subagent/tools.ts +13 -14
- package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
- package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
- package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
- package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
- package/src/config/activation.ts +140 -0
- package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
- package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
- package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
- package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
- package/src/{runtime-definition.ts → config/definition.ts} +13 -13
- package/src/config/harness-runtime.ts +182 -0
- package/src/config/index.ts +14 -0
- package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
- package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
- package/src/{kernel → config}/profile.ts +7 -0
- package/src/{kernel → config}/public-contracts.ts +6 -6
- package/src/{kernel → config}/runtime-load.ts +1 -1
- package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
- package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
- package/src/context/extension/index.ts +3 -0
- package/src/context/index.ts +6 -0
- package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
- package/src/conversation/harness-resume.ts +288 -0
- package/src/conversation/harness-stream.ts +98 -0
- package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
- package/src/conversation/index.ts +6 -0
- package/src/{pi → conversation}/message/contract.ts +3 -0
- package/src/{pi → conversation}/message/conversion.ts +75 -0
- package/src/{pi → conversation}/message/index.ts +1 -0
- package/src/{pi → conversation}/message/model-input.ts +1 -1
- package/src/{pi → conversation}/message/projection.ts +76 -10
- package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
- package/src/conversation/state.ts +175 -0
- package/src/harness/index.ts +7 -0
- package/src/harness/session-harness.ts +481 -0
- package/src/harness/submission-operation.ts +49 -0
- package/src/index.ts +38 -43
- package/src/lib/mcp.ts +1 -1
- package/src/{pi/runtime-adapter/models.ts → models/index.ts} +64 -18
- package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
- package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
- package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
- package/src/session/index.ts +39 -0
- package/src/submission/admission.ts +275 -0
- package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
- package/src/submission/delivery.ts +212 -0
- package/src/submission/finalization.ts +111 -0
- package/src/submission/index.ts +9 -0
- package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
- package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
- package/src/submission/steer-delivery.ts +167 -0
- package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
- package/src/telemetry/contract.ts +12 -0
- package/src/telemetry/coordinator.ts +65 -0
- package/src/telemetry/delivery.ts +2 -2
- package/src/telemetry/index.ts +5 -0
- package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
- package/src/telemetry/recorder.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +12 -0
- package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
- package/src/telemetry/turn-events.ts +279 -0
- package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
- package/src/tools/approval/index.ts +1 -0
- package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
- package/src/{pi/tool → tools}/base.ts +14 -30
- package/src/tools/compiler.ts +327 -0
- package/src/{pi/tool → tools}/core-host.ts +3 -3
- package/src/{pi/tool → tools}/core.ts +3 -3
- package/src/tools/decision-gate.ts +227 -0
- package/src/{pi/tool → tools}/gateway.ts +1 -1
- package/src/tools/human-decisions.ts +127 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/interaction/index.ts +1 -0
- package/src/{pi/tool → tools}/mcp.ts +1 -1
- package/src/{pi/tool → tools}/schedule.ts +3 -3
- package/src/{pi/tool → tools}/skill.ts +4 -4
- package/src/{kernel → tools}/subagent-runtime.ts +1 -1
- package/src/{pi/tool → tools}/subagent.ts +4 -4
- package/src/tools/temporary-agent/approvals.ts +126 -0
- package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
- package/src/tools/temporary-agent/core.ts +53 -0
- package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
- package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
- package/src/{pi/tool → tools}/web-search/api.ts +1 -1
- package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
- package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
- package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
- package/src/db/milestone.repo.ts +0 -104
- package/src/db/settlement.repo.ts +0 -104
- package/src/kernel/approval-lifecycle.ts +0 -638
- package/src/kernel/interaction-lifecycle.ts +0 -433
- package/src/kernel/recoverable-chat-agent.ts +0 -1020
- package/src/kernel/state.ts +0 -91
- package/src/pi/assembly/index.ts +0 -19
- package/src/pi/runtime-adapter/execution.ts +0 -1163
- package/src/pi/runtime-adapter/index.ts +0 -293
- package/src/pi/runtime-adapter/recovery.ts +0 -982
- package/src/pi/session/index.ts +0 -24
- package/src/pi/tool/compiler.ts +0 -483
- package/src/pi/tool/index.ts +0 -38
- package/src/pi/turn/approval.ts +0 -181
- package/src/pi/turn/index.ts +0 -82
- package/src/pi/turn/interaction.ts +0 -181
- package/src/runtime.ts +0 -4760
- /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
- /package/src/{kernel → config}/degradation.ts +0 -0
- /package/src/{kernel → config}/runtime-config.ts +0 -0
- /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
- /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
- /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
- /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
- /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
- /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
- /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
- /package/src/{pi/tool → tools}/declared.ts +0 -0
- /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
- /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
- /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
- /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
- /package/src/{pi/tool → tools}/time.ts +0 -0
- /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
- /package/src/{workspace-versioning.ts → tools/workspace-versioning.ts} +0 -0
|
@@ -1,212 +1,12 @@
|
|
|
1
|
-
import { summarizeModelUsage } from "../../model-usage";
|
|
2
|
-
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core/harness/context";
|
|
3
|
-
import {
|
|
4
|
-
buildSessionContext,
|
|
5
|
-
compact as compactPiTranscript,
|
|
6
|
-
DEFAULT_COMPACTION_SETTINGS,
|
|
7
|
-
estimateContextTokens,
|
|
8
|
-
estimateTokens,
|
|
9
|
-
prepareCompaction,
|
|
10
|
-
type AgentMessage,
|
|
11
|
-
type CompactionEntry,
|
|
12
|
-
type Entry,
|
|
13
|
-
} from "@earendil-works/pi-agent-core";
|
|
14
|
-
import type {
|
|
15
|
-
AssistantMessage,
|
|
16
|
-
Model,
|
|
17
|
-
Models,
|
|
18
|
-
RetryPolicy,
|
|
19
|
-
Usage,
|
|
20
|
-
} from "@earendil-works/pi-ai";
|
|
21
1
|
import { estimateStringTokens } from "agents/experimental/memory/utils";
|
|
22
|
-
import type { RuntimeMemoryPort } from "
|
|
23
|
-
import type { RuntimeDegradation } from "
|
|
24
|
-
import type { RuntimeMemoryProfile } from "
|
|
25
|
-
import type { PiExtensionContextContribution } from "./
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 压缩这一次 LLM 调用的重试策略。
|
|
29
|
-
*
|
|
30
|
-
* 为什么必须显式传:pi 的 `retryAssistantCall` 是 opt-in 的 —— d.ts 原话
|
|
31
|
-
* 「When `policy` is undefined or disabled, the first response is returned
|
|
32
|
-
* unchanged」。升到 0.83 不传这个参数,行为与 0.80.6 完全一样(零重试)。
|
|
33
|
-
*
|
|
34
|
-
* 为什么压缩值得重试:压缩失败不是「少一次摘要」,是**下一轮直接撞 provider
|
|
35
|
-
* 的上下文窗口上限、整个 turn 挂掉**。而它跑在 Workers 里,瞬时网络错误是常态。
|
|
36
|
-
*
|
|
37
|
-
* 为什么只有 2 次、退避从 500ms 起:压缩发生在 turn 的关键路径上,DO 的墙钟
|
|
38
|
-
* 是真金白银(等 LLM 不占 CPU 配额但占用户的等待)。2 次退避总计约 1.5s,
|
|
39
|
-
* 够穿过瞬时抖动,又不至于把一次失败拖成用户可感知的卡顿。
|
|
40
|
-
*/
|
|
41
|
-
const COMPACTION_RETRY: RetryPolicy = {
|
|
42
|
-
enabled: true,
|
|
43
|
-
maxRetries: 2,
|
|
44
|
-
baseDelayMs: 500,
|
|
45
|
-
};
|
|
2
|
+
import type { RuntimeMemoryPort } from "../config/ports";
|
|
3
|
+
import type { RuntimeDegradation } from "../config/degradation";
|
|
4
|
+
import type { RuntimeMemoryProfile } from "../config/profile";
|
|
5
|
+
import type { PiExtensionContextContribution } from "./extension/assembly";
|
|
46
6
|
|
|
47
7
|
const COLD_MEMORY_INDEX = "/userspace/memories/MEMORY.md";
|
|
48
8
|
const TRUNCATION_MARKER = "\n[truncated to memory budget]";
|
|
49
9
|
|
|
50
|
-
function compactionUsage(responses: readonly AssistantMessage[], requested: Model<any>) {
|
|
51
|
-
const [first, ...remaining] = responses;
|
|
52
|
-
return first ? { modelUsage: summarizeModelUsage(requested, [first, ...remaining]) } : {};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// #region Transcript compaction
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* 把当前 Pi 会话分支压缩成下一轮可以直接使用的消息。
|
|
59
|
-
*
|
|
60
|
-
* @remarks
|
|
61
|
-
* Transcript 在每轮开始前传入当前分支、模型和 token 阈值,并在返回压缩条目后自行追加到 Session。
|
|
62
|
-
*
|
|
63
|
-
* 这里先用 Pi 的 Session 管线重建上下文,任何失败都退回未压缩消息,以避免压缩失败损坏持久化会话。
|
|
64
|
-
*
|
|
65
|
-
* 核心术语见包入口 `index.ts`。
|
|
66
|
-
*/
|
|
67
|
-
export async function compactPiContext(input: Readonly<{
|
|
68
|
-
branch: readonly Entry[];
|
|
69
|
-
compactAfterTokens: number;
|
|
70
|
-
force?: boolean;
|
|
71
|
-
models: Models;
|
|
72
|
-
model: Model<any>;
|
|
73
|
-
signal?: AbortSignal;
|
|
74
|
-
}>): Promise<{
|
|
75
|
-
messages: AgentMessage[];
|
|
76
|
-
compactionEntry?: Omit<
|
|
77
|
-
CompactionEntry,
|
|
78
|
-
"id" | "parentId" | "seq" | "timestamp"
|
|
79
|
-
>;
|
|
80
|
-
modelUsage?: {
|
|
81
|
-
api: string;
|
|
82
|
-
provider: string;
|
|
83
|
-
model: string;
|
|
84
|
-
responseModel?: string;
|
|
85
|
-
stopReason?: string;
|
|
86
|
-
usage: Usage;
|
|
87
|
-
};
|
|
88
|
-
didCompact: boolean;
|
|
89
|
-
degraded: boolean;
|
|
90
|
-
}> {
|
|
91
|
-
const responses: AssistantMessage[] = [];
|
|
92
|
-
const branch = [...input.branch];
|
|
93
|
-
const context = input.signal ? withAbortSignal(input.signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT;
|
|
94
|
-
let currentMessages = branch.flatMap((entry) =>
|
|
95
|
-
entry.type === "message" ? [entry.message] : []
|
|
96
|
-
);
|
|
97
|
-
try {
|
|
98
|
-
currentMessages = await buildSessionContext(branch, undefined, context);
|
|
99
|
-
const settings = DEFAULT_COMPACTION_SETTINGS;
|
|
100
|
-
if (
|
|
101
|
-
!settings.enabled ||
|
|
102
|
-
(!input.force && estimateContextTokens(currentMessages).tokens <
|
|
103
|
-
input.compactAfterTokens)
|
|
104
|
-
) {
|
|
105
|
-
return {
|
|
106
|
-
messages: currentMessages,
|
|
107
|
-
didCompact: false,
|
|
108
|
-
degraded: false,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const preparation = prepareCompaction(branch, settings);
|
|
113
|
-
if (!preparation.ok) {
|
|
114
|
-
return {
|
|
115
|
-
messages: currentMessages,
|
|
116
|
-
didCompact: false,
|
|
117
|
-
degraded: true,
|
|
118
|
-
...compactionUsage(responses, input.model),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
if (!preparation.value) {
|
|
122
|
-
return {
|
|
123
|
-
messages: currentMessages,
|
|
124
|
-
didCompact: false,
|
|
125
|
-
degraded: false,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const models = {
|
|
130
|
-
completeSimple: async (
|
|
131
|
-
model: Model<any>,
|
|
132
|
-
context: Parameters<Models["completeSimple"]>[1],
|
|
133
|
-
options?: Parameters<Models["completeSimple"]>[2],
|
|
134
|
-
) => {
|
|
135
|
-
const response = await input.models.completeSimple(model, context, options);
|
|
136
|
-
responses.push(response);
|
|
137
|
-
input.signal?.throwIfAborted();
|
|
138
|
-
return response;
|
|
139
|
-
},
|
|
140
|
-
} as Models;
|
|
141
|
-
const compacted = await compactPiTranscript(
|
|
142
|
-
preparation.value,
|
|
143
|
-
models,
|
|
144
|
-
input.model,
|
|
145
|
-
undefined,
|
|
146
|
-
undefined,
|
|
147
|
-
COMPACTION_RETRY,
|
|
148
|
-
undefined,
|
|
149
|
-
context,
|
|
150
|
-
);
|
|
151
|
-
if (!compacted.ok) {
|
|
152
|
-
return {
|
|
153
|
-
messages: currentMessages,
|
|
154
|
-
didCompact: false,
|
|
155
|
-
degraded: true,
|
|
156
|
-
...compactionUsage(responses, input.model),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const compactionEntry = {
|
|
161
|
-
type: "compaction",
|
|
162
|
-
fromHook: false,
|
|
163
|
-
...compacted.value,
|
|
164
|
-
} satisfies Omit<
|
|
165
|
-
CompactionEntry,
|
|
166
|
-
"id" | "parentId" | "seq" | "timestamp"
|
|
167
|
-
>;
|
|
168
|
-
const previewEntry: CompactionEntry = {
|
|
169
|
-
...compactionEntry,
|
|
170
|
-
id: `compaction-preview:${branch[branch.length - 1]?.id ?? "root"}`,
|
|
171
|
-
parentId: branch[branch.length - 1]?.id ?? null,
|
|
172
|
-
timestamp: Date.now(),
|
|
173
|
-
seq: (branch.at(-1)?.seq ?? 0) + 1,
|
|
174
|
-
};
|
|
175
|
-
const messages = await buildSessionContext(
|
|
176
|
-
[...branch, previewEntry], undefined, context,
|
|
177
|
-
);
|
|
178
|
-
if (
|
|
179
|
-
input.force &&
|
|
180
|
-
messages.reduce((total, message) => total + estimateTokens(message), 0) >=
|
|
181
|
-
currentMessages.reduce(
|
|
182
|
-
(total, message) => total + estimateTokens(message),
|
|
183
|
-
0,
|
|
184
|
-
)
|
|
185
|
-
) {
|
|
186
|
-
return {
|
|
187
|
-
messages: currentMessages,
|
|
188
|
-
didCompact: false,
|
|
189
|
-
degraded: true,
|
|
190
|
-
...compactionUsage(responses, input.model),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
return {
|
|
194
|
-
messages,
|
|
195
|
-
compactionEntry,
|
|
196
|
-
...compactionUsage(responses, input.model),
|
|
197
|
-
didCompact: true,
|
|
198
|
-
degraded: false,
|
|
199
|
-
};
|
|
200
|
-
} catch {
|
|
201
|
-
return {
|
|
202
|
-
messages: currentMessages,
|
|
203
|
-
didCompact: false,
|
|
204
|
-
degraded: true,
|
|
205
|
-
...compactionUsage(responses, input.model),
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
10
|
// 作用:把一段上下文裁到指定 token 预算内。
|
|
211
11
|
// 调用:内存和 Extension 上下文在拼入 system prompt 前调用。
|
|
212
12
|
// 原因:二分搜索每次都用同一个 token 估算器验证,改成字符数截断会突破真实预算。
|
|
@@ -246,8 +46,6 @@ function renderBlock(
|
|
|
246
46
|
return `${separator}\n${label} (${description})\n${separator}\n${content}`;
|
|
247
47
|
}
|
|
248
48
|
|
|
249
|
-
// #endregion
|
|
250
|
-
|
|
251
49
|
// #region System context sources
|
|
252
50
|
|
|
253
51
|
// 作用:同时收集当前 Session 内存和用户管理的冷内存索引。
|
|
@@ -8,12 +8,12 @@ import type {
|
|
|
8
8
|
LoadedRuntimeExtension,
|
|
9
9
|
RuntimeExtensionConfig,
|
|
10
10
|
RuntimeExtensionPermissions,
|
|
11
|
-
} from "
|
|
12
|
-
import type { RuntimeDegradation } from "../../
|
|
13
|
-
import { withRuntimeLoadTimeout } from "../../
|
|
11
|
+
} from "./contracts";
|
|
12
|
+
import type { RuntimeDegradation } from "../../config/degradation";
|
|
13
|
+
import { withRuntimeLoadTimeout } from "../../config/runtime-load";
|
|
14
14
|
import { sanitizeExtensionName } from "../../lib/extension-name";
|
|
15
|
-
import type { PiToolCandidate } from "
|
|
16
|
-
import { createPiDeclaredToolCandidate } from "
|
|
15
|
+
import type { PiToolCandidate } from "../../tools/compiler";
|
|
16
|
+
import { createPiDeclaredToolCandidate } from "../../tools/declared";
|
|
17
17
|
|
|
18
18
|
export interface PiExtensionContextContribution {
|
|
19
19
|
readonly extensionName: string;
|
|
@@ -44,8 +44,8 @@ export const SPILL_THRESHOLD = 32 * 1024;
|
|
|
44
44
|
/**
|
|
45
45
|
* 历史里一条旧 Tool 结果重放多少。
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* 这条最紧,因为 `projectToolOutputForModel` 会把它应用到每个 Turn
|
|
48
|
+
* 重放的全部历史 Tool 结果:
|
|
49
49
|
* 放宽 1 KB 就是 N 条历史 × 1 KB。
|
|
50
50
|
*/
|
|
51
51
|
export const MODEL_REPLAY_THRESHOLD = 16 * 1024;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { HarnessClosed, type AgentLane, type LaneSnapshot, type WatchHandle } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
|
|
3
|
+
import type { OperationResultRecord } from "@earendil-works/pi-agent-core/harness/session";
|
|
4
|
+
import type { Connection } from "agents";
|
|
5
|
+
import { MessageType } from "agents/chat";
|
|
6
|
+
import type { UIMessage, UIMessageChunk } from "ai";
|
|
7
|
+
import type { ConversationHistory } from "./history";
|
|
8
|
+
import { HarnessStreamProjection } from "./harness-stream";
|
|
9
|
+
|
|
10
|
+
interface ActiveConversation {
|
|
11
|
+
lane: AgentLane;
|
|
12
|
+
submissionId: string;
|
|
13
|
+
requestId: string;
|
|
14
|
+
assistantMessageId: string;
|
|
15
|
+
userMessageId?: string | null;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
}
|
|
18
|
+
interface ResumeConnection {
|
|
19
|
+
connection: Connection;
|
|
20
|
+
source: ActiveConversation;
|
|
21
|
+
ready: Promise<void>;
|
|
22
|
+
watch?: WatchHandle<LaneSnapshot>;
|
|
23
|
+
projection?: HarnessStreamProjection;
|
|
24
|
+
acknowledging: boolean;
|
|
25
|
+
replay?: Promise<void>;
|
|
26
|
+
terminal?: OperationResultRecord;
|
|
27
|
+
released?: boolean;
|
|
28
|
+
finished?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/** CF's resume handshake, with its replay prefix and subsequent events sourced only from Pi. */
|
|
33
|
+
export class HarnessConversationBridge {
|
|
34
|
+
private active?: ActiveConversation;
|
|
35
|
+
private readonly connections = new Map<string, ResumeConnection>();
|
|
36
|
+
|
|
37
|
+
constructor(private readonly host: {
|
|
38
|
+
history: Pick<ConversationHistory, "browserMessages" | "browserSnapshot">;
|
|
39
|
+
send: (connection: Connection, body: string) => void | Promise<void>;
|
|
40
|
+
broadcast: (body: string, excludedConnectionIds: string[]) => void;
|
|
41
|
+
}) {}
|
|
42
|
+
|
|
43
|
+
activate(lane: AgentLane, submission: Omit<ActiveConversation, "lane">): void {
|
|
44
|
+
this.active = { lane, ...submission };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private replayMessage(messages: UIMessage[], submissionId: string): UIMessage | undefined {
|
|
48
|
+
return messages.filter((message) => message.role === "assistant" &&
|
|
49
|
+
(message.metadata as { turnId?: string } | undefined)?.turnId === submissionId).at(-1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private historyBeforeReplay(messages: UIMessage[], submissionId: string): UIMessage[] {
|
|
53
|
+
const target = this.replayMessage(messages, submissionId);
|
|
54
|
+
return target ? messages.filter((message) => message.id !== target.id) : messages;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reads never release lifecycle state; hydration leaves the active reply to replay. */
|
|
58
|
+
async messages(view: "full" | "hydration" = "full"): Promise<UIMessage[]> {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const source = this.active;
|
|
61
|
+
let watch: WatchHandle<LaneSnapshot> | undefined;
|
|
62
|
+
if (source) {
|
|
63
|
+
try { watch = await source.lane.watch(BACKGROUND_CONTEXT); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!(error instanceof HarnessClosed)) throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const messages = watch
|
|
70
|
+
? await this.host.history.browserSnapshot(watch.snapshot)
|
|
71
|
+
: await this.host.history.browserMessages();
|
|
72
|
+
if (this.active !== source) continue;
|
|
73
|
+
return view === "hydration" && source && watch?.snapshot.operation?.id === source.submissionId
|
|
74
|
+
? this.historyBeforeReplay(messages, source.submissionId)
|
|
75
|
+
: messages;
|
|
76
|
+
} finally { watch?.unsubscribe(); }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async send(connection: Connection, frame: unknown): Promise<void> {
|
|
81
|
+
const session = this.connections.get(connection.id);
|
|
82
|
+
try { await this.host.send(connection, JSON.stringify(frame)); }
|
|
83
|
+
catch { if (session) this.discard(session); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async sendHistory(connection: Connection, messages: UIMessage[]): Promise<void> {
|
|
87
|
+
await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async sendIdleHistory(connection: Connection, probeId?: string): Promise<void> {
|
|
91
|
+
const source = this.active;
|
|
92
|
+
const session = this.connections.get(connection.id);
|
|
93
|
+
const messages = await this.host.history.browserMessages();
|
|
94
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
95
|
+
if (this.active !== source) return this.resume(connection, probeId);
|
|
96
|
+
await this.sendHistory(connection, messages);
|
|
97
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
98
|
+
if (this.active !== source) return this.resume(connection, probeId);
|
|
99
|
+
await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, ...(probeId ? { probeId } : {}) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async sendChunks(session: ResumeConnection, chunks: readonly UIMessageChunk[], replay = false): Promise<void> {
|
|
103
|
+
for (const chunk of chunks) {
|
|
104
|
+
if (this.connections.get(session.connection.id) !== session) return;
|
|
105
|
+
await this.send(session.connection, {
|
|
106
|
+
type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
|
|
107
|
+
id: session.source.requestId,
|
|
108
|
+
body: JSON.stringify(chunk), done: false,
|
|
109
|
+
...(replay ? { replay: true, continuation: false } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async resume(connection: Connection, probeId?: string): Promise<void> {
|
|
115
|
+
const source = this.active;
|
|
116
|
+
if (!source) {
|
|
117
|
+
return this.sendIdleHistory(connection, probeId);
|
|
118
|
+
}
|
|
119
|
+
let session = this.connections.get(connection.id);
|
|
120
|
+
if (!session || session.source !== source) {
|
|
121
|
+
this.disconnect(connection);
|
|
122
|
+
session = { connection, source, ready: Promise.resolve(), acknowledging: false };
|
|
123
|
+
this.connections.set(connection.id, session);
|
|
124
|
+
const current = session;
|
|
125
|
+
current.ready = (async () => {
|
|
126
|
+
const watch = await source.lane.watch(BACKGROUND_CONTEXT);
|
|
127
|
+
if (this.connections.get(connection.id) !== current) { watch.unsubscribe(); return; }
|
|
128
|
+
current.watch = watch;
|
|
129
|
+
})();
|
|
130
|
+
}
|
|
131
|
+
try { await session.ready; }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
134
|
+
this.discard(session);
|
|
135
|
+
if (this.active !== source) return this.resume(connection, probeId);
|
|
136
|
+
if (error instanceof HarnessClosed) {
|
|
137
|
+
await this.sendIdleHistory(connection, probeId);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
142
|
+
if (this.active !== source) {
|
|
143
|
+
this.discard(session);
|
|
144
|
+
return this.resume(connection, probeId);
|
|
145
|
+
}
|
|
146
|
+
const snapshot = session.watch?.snapshot;
|
|
147
|
+
if (!snapshot?.operation || snapshot.operation.id !== source.submissionId) {
|
|
148
|
+
this.discard(session);
|
|
149
|
+
return this.sendIdleHistory(connection, probeId);
|
|
150
|
+
}
|
|
151
|
+
if (!session.acknowledging) {
|
|
152
|
+
const messages = await this.host.history.browserSnapshot(snapshot);
|
|
153
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
154
|
+
if (this.active !== source) { this.discard(session); return this.resume(connection, probeId); }
|
|
155
|
+
await this.sendHistory(connection, this.historyBeforeReplay(messages, source.submissionId));
|
|
156
|
+
}
|
|
157
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
158
|
+
if (this.active !== source) { this.discard(session); return this.resume(connection, probeId); }
|
|
159
|
+
await this.send(connection, {
|
|
160
|
+
type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId,
|
|
161
|
+
...(probeId ? { probeId } : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async acknowledge(connection: Connection, requestId: string): Promise<void> {
|
|
166
|
+
const session = this.connections.get(connection.id);
|
|
167
|
+
if (!session || session.source.requestId !== requestId || session.acknowledging) return;
|
|
168
|
+
if (this.active && this.active.submissionId !== session.source.submissionId) {
|
|
169
|
+
// A queued Submission may already be live when an older offer is ACKed.
|
|
170
|
+
// End the old transport, then atomically resume the current lane view.
|
|
171
|
+
this.disconnect(connection);
|
|
172
|
+
const current = this.resume(connection);
|
|
173
|
+
await this.send(connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: requestId, body: "", done: true });
|
|
174
|
+
await current;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
session.acknowledging = true;
|
|
178
|
+
session.replay = this.replay(session);
|
|
179
|
+
try {
|
|
180
|
+
await session.replay;
|
|
181
|
+
if (session.terminal) await this.finish(session, session.terminal);
|
|
182
|
+
if (session.released) this.discard(session);
|
|
183
|
+
} catch { this.discard(session); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async replay(session: ResumeConnection): Promise<void> {
|
|
187
|
+
const connection = session.connection;
|
|
188
|
+
const requestId = session.source.requestId;
|
|
189
|
+
await session.ready;
|
|
190
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
191
|
+
if (!session.watch && this.active?.submissionId === session.source.submissionId) {
|
|
192
|
+
session.source = this.active;
|
|
193
|
+
session.released = false;
|
|
194
|
+
const watch = await this.active.lane.watch(BACKGROUND_CONTEXT);
|
|
195
|
+
if (this.connections.get(connection.id) !== session) { watch.unsubscribe(); return; }
|
|
196
|
+
session.watch = watch;
|
|
197
|
+
}
|
|
198
|
+
const watch = session.watch;
|
|
199
|
+
const messages = watch ? await this.host.history.browserSnapshot(watch.snapshot) : await this.host.history.browserMessages();
|
|
200
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
201
|
+
const source = session.source;
|
|
202
|
+
const message = this.replayMessage(messages, source.submissionId) ?? {
|
|
203
|
+
id: source.assistantMessageId, role: "assistant" as const, parts: [],
|
|
204
|
+
metadata: { turnId: source.submissionId, userMessageId: source.userMessageId,
|
|
205
|
+
createdAt: source.createdAt, turnStatus: "running" },
|
|
206
|
+
};
|
|
207
|
+
const projection = new HarnessStreamProjection({
|
|
208
|
+
messageId: message.id, startedAt: source.createdAt, turnId: source.submissionId,
|
|
209
|
+
userMessageId: source.userMessageId,
|
|
210
|
+
});
|
|
211
|
+
session.projection = projection;
|
|
212
|
+
const prefix = projection.restore(message, Boolean(watch?.snapshot.operation?.streamingMessage));
|
|
213
|
+
await this.sendChunks(session, prefix, true);
|
|
214
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
215
|
+
await this.send(connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
|
|
216
|
+
id: requestId, body: "", done: false, replayComplete: true });
|
|
217
|
+
if (this.connections.get(connection.id) !== session) return;
|
|
218
|
+
watch?.start(async (event) => {
|
|
219
|
+
if ("runId" in event && event.runId !== source.submissionId) return;
|
|
220
|
+
await this.sendChunks(session, projection.event(event));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async finish(session: ResumeConnection, record: OperationResultRecord): Promise<void> {
|
|
225
|
+
if (session.finished || !session.projection) return;
|
|
226
|
+
session.finished = true;
|
|
227
|
+
await session.watch?.drain();
|
|
228
|
+
await this.sendChunks(session, session.projection.finish(record));
|
|
229
|
+
if (this.connections.get(session.connection.id) === session) {
|
|
230
|
+
await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
|
|
231
|
+
id: session.source.requestId, body: "", done: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Called only after the Host has persisted the Submission's terminal result. */
|
|
236
|
+
async settle(record: OperationResultRecord): Promise<void> {
|
|
237
|
+
await Promise.all([...this.connections.values()].filter((session) =>
|
|
238
|
+
session.source.submissionId === record.operationId).map(async (session) => {
|
|
239
|
+
session.terminal = record;
|
|
240
|
+
try {
|
|
241
|
+
if (session.replay) await session.replay;
|
|
242
|
+
await this.finish(session, record);
|
|
243
|
+
} catch { this.discard(session); }
|
|
244
|
+
}));
|
|
245
|
+
if (this.active?.submissionId === record.operationId) {
|
|
246
|
+
this.broadcastResponse({ id: this.active.requestId, body: "", done: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Called after a drive returns, outside Pi event listeners. */
|
|
251
|
+
async release(lane: AgentLane): Promise<void> {
|
|
252
|
+
if (this.active?.lane === lane) this.active = undefined;
|
|
253
|
+
await Promise.all([...this.connections.values()].filter((session) => session.source.lane === lane)
|
|
254
|
+
.map(async (session) => {
|
|
255
|
+
try {
|
|
256
|
+
await session.ready;
|
|
257
|
+
if (session.replay) await session.replay;
|
|
258
|
+
if (session.projection) {
|
|
259
|
+
await session.watch?.drain();
|
|
260
|
+
this.discard(session);
|
|
261
|
+
} else {
|
|
262
|
+
session.watch?.unsubscribe();
|
|
263
|
+
session.watch = undefined;
|
|
264
|
+
session.released = true;
|
|
265
|
+
}
|
|
266
|
+
} catch { this.discard(session); }
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
disconnect(connection: Pick<Connection, "id">): void {
|
|
271
|
+
const session = this.connections.get(connection.id);
|
|
272
|
+
if (session) this.discard(session);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private discard(session: ResumeConnection): void {
|
|
276
|
+
if (this.connections.get(session.connection.id) !== session) return;
|
|
277
|
+
session.watch?.unsubscribe();
|
|
278
|
+
this.connections.delete(session.connection.id);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
broadcastResponse(frame: { id: string; body: string; done: boolean; error?: boolean; continuation?: boolean }): void {
|
|
282
|
+
// Approval is a Host fact, so it has no duplicate Pi event for the watch to project.
|
|
283
|
+
const isApproval = frame.body && JSON.parse(frame.body).type === "tool-approval-request";
|
|
284
|
+
const excluded = isApproval ? [] : [...this.connections.values()]
|
|
285
|
+
.filter((session) => session.source.requestId === frame.id).map(({ connection }) => connection.id);
|
|
286
|
+
this.host.broadcast(JSON.stringify({ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, ...frame }), excluded);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AgentEvent, Events, HarnessEvent } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { OperationResultRecord } from "@earendil-works/pi-agent-core/harness/session";
|
|
3
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { UIMessage, UIMessageChunk } from "ai";
|
|
5
|
+
import { PiChunkEncoder } from "./message";
|
|
6
|
+
|
|
7
|
+
export interface HarnessStreamOptions {
|
|
8
|
+
readonly messageId: string;
|
|
9
|
+
readonly startedAt: number;
|
|
10
|
+
readonly turnId: string;
|
|
11
|
+
readonly userMessageId?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Project the final Host decision without rewriting Pi's canonical operation result. */
|
|
15
|
+
export function projectSubmissionOutcome(
|
|
16
|
+
record: OperationResultRecord,
|
|
17
|
+
submission: { status: string; completedAt?: number | null; error?: string | null },
|
|
18
|
+
): OperationResultRecord {
|
|
19
|
+
const status = submission.status === "aborted" ? "aborted"
|
|
20
|
+
: submission.status === "error" ? "failed"
|
|
21
|
+
: submission.status === "skipped" ? "declined" : "completed";
|
|
22
|
+
const { error: _previousError, ...result } = record;
|
|
23
|
+
return {
|
|
24
|
+
...result, status, endedAt: submission.completedAt ?? record.endedAt,
|
|
25
|
+
...((status === "failed" || status === "aborted") && submission.error
|
|
26
|
+
? { error: { code: status, message: submission.error } } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One projection for both broadcast events and a resumed Pi watch. */
|
|
31
|
+
export class HarnessStreamProjection {
|
|
32
|
+
private readonly encoder: PiChunkEncoder;
|
|
33
|
+
private terminal: AssistantMessage | undefined;
|
|
34
|
+
|
|
35
|
+
constructor(options: HarnessStreamOptions) {
|
|
36
|
+
this.encoder = new PiChunkEncoder(options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
restore(message: UIMessage, streaming: boolean): UIMessageChunk[] {
|
|
40
|
+
return this.encoder.restore(message, streaming);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
event(event: HarnessEvent): UIMessageChunk[] {
|
|
44
|
+
switch (event.type) {
|
|
45
|
+
case "run_start":
|
|
46
|
+
case "run_resume": return this.encoder.start();
|
|
47
|
+
case "turn_start":
|
|
48
|
+
this.terminal = undefined;
|
|
49
|
+
return this.encoder.encode({ type: "turn_start" } as AgentEvent);
|
|
50
|
+
case "message_update":
|
|
51
|
+
return event.event.type === "error" ? [] : this.encoder.encode({
|
|
52
|
+
type: "message_update", message: event.message, assistantMessageEvent: event.event,
|
|
53
|
+
} as AgentEvent);
|
|
54
|
+
case "tool_start": return this.encoder.encode({
|
|
55
|
+
type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args,
|
|
56
|
+
} as AgentEvent);
|
|
57
|
+
case "tool_update": return this.encoder.encode({
|
|
58
|
+
type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: event.partialResult,
|
|
59
|
+
} as AgentEvent);
|
|
60
|
+
case "tool_end": return this.encoder.encode({
|
|
61
|
+
type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError,
|
|
62
|
+
} as AgentEvent);
|
|
63
|
+
case "message_end":
|
|
64
|
+
if (event.message.role !== "assistant") return [];
|
|
65
|
+
this.terminal = event.message;
|
|
66
|
+
return this.encoder.encode({ type: "message_end", message: event.message } as AgentEvent, { terminal: false });
|
|
67
|
+
default: return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] {
|
|
72
|
+
return this.encoder.approvalRequested(toolCallId, approvalId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
finish(result: Pick<OperationResultRecord, "status" | "endedAt" | "error">): UIMessageChunk[] {
|
|
76
|
+
return this.encoder.finish({
|
|
77
|
+
stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" :
|
|
78
|
+
this.terminal?.stopReason === "length" ? "length" : "stop",
|
|
79
|
+
timestamp: result.endedAt,
|
|
80
|
+
...(result.error?.message ? { errorMessage: result.error.message } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const STREAM_EVENTS = ["run_start", "run_resume", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "message_end"] as const;
|
|
86
|
+
|
|
87
|
+
export function bindHarnessStream(
|
|
88
|
+
events: Events,
|
|
89
|
+
options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> },
|
|
90
|
+
) {
|
|
91
|
+
const projection = new HarnessStreamProjection(options);
|
|
92
|
+
const unsubscribers = STREAM_EVENTS.map((type) => events.on(type, (event) => options.publish(projection.event(event))));
|
|
93
|
+
return {
|
|
94
|
+
publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
|
|
95
|
+
flushTerminal: (result: OperationResultRecord) => options.publish(projection.finish(result)),
|
|
96
|
+
unsubscribe: () => { for (const unsubscribe of unsubscribers) unsubscribe(); },
|
|
97
|
+
};
|
|
98
|
+
}
|