@springbrand/agent-runtime 0.1.0
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 +28 -0
- package/src/db/approval.repo.ts +291 -0
- package/src/db/ext-context.repo.ts +34 -0
- package/src/db/index.ts +83 -0
- package/src/db/message-ui.repo.ts +39 -0
- package/src/db/milestone.repo.ts +96 -0
- package/src/db/runtime-event-outbox.repo.ts +89 -0
- package/src/db/schema.ts +164 -0
- package/src/db/settlement.repo.ts +104 -0
- package/src/db/steer.repo.ts +73 -0
- package/src/db/submission.repo.ts +323 -0
- package/src/index.ts +133 -0
- package/src/kernel/approval-lifecycle.ts +552 -0
- package/src/kernel/bindings.ts +898 -0
- package/src/kernel/degradation.ts +15 -0
- package/src/kernel/extensions.ts +108 -0
- package/src/kernel/profile.ts +116 -0
- package/src/kernel/public-contracts.ts +17 -0
- package/src/kernel/receipts.ts +124 -0
- package/src/kernel/recoverable-chat-agent.ts +899 -0
- package/src/kernel/state.ts +76 -0
- package/src/kernel/submission-lifecycle.ts +600 -0
- package/src/layers/context/budget/gate.ts +88 -0
- package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
- package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
- package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
- package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
- package/src/layers/orchestration/temporary-agent/core.ts +152 -0
- package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
- package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
- package/src/lib/artifacts.ts +54 -0
- package/src/lib/egress.ts +44 -0
- package/src/lib/execution-level.ts +27 -0
- package/src/lib/extension-name.ts +18 -0
- package/src/lib/host-actions.ts +57 -0
- package/src/lib/mcp.ts +86 -0
- package/src/lib/model-catalog.ts +7 -0
- package/src/lib/prompt.ts +139 -0
- package/src/lib/telemetry-dev.ts +44 -0
- package/src/pi/assembly/context.ts +510 -0
- package/src/pi/assembly/extensions.ts +661 -0
- package/src/pi/assembly/index.ts +19 -0
- package/src/pi/assembly/snapshot.ts +200 -0
- package/src/pi/message/contract.ts +8 -0
- package/src/pi/message/conversion.ts +73 -0
- package/src/pi/message/index.ts +3 -0
- package/src/pi/message/projection.ts +604 -0
- package/src/pi/runtime-adapter/assembly.ts +552 -0
- package/src/pi/runtime-adapter/execution.ts +683 -0
- package/src/pi/runtime-adapter/index.ts +232 -0
- package/src/pi/runtime-adapter/models.ts +243 -0
- package/src/pi/runtime-adapter/recovery.ts +805 -0
- package/src/pi/runtime-adapter/transcript.ts +825 -0
- package/src/pi/session/index.ts +24 -0
- package/src/pi/session/storage.ts +353 -0
- package/src/pi/tool/ai-adapter.ts +100 -0
- package/src/pi/tool/base.ts +110 -0
- package/src/pi/tool/compiler.ts +444 -0
- package/src/pi/tool/core-host.ts +48 -0
- package/src/pi/tool/core.ts +251 -0
- package/src/pi/tool/index.ts +32 -0
- package/src/pi/tool/mcp.ts +319 -0
- package/src/pi/tool/schedule.ts +198 -0
- package/src/pi/tool/skill.ts +455 -0
- package/src/pi/tool/subagent.ts +148 -0
- package/src/pi/tool/web-search/api.ts +1292 -0
- package/src/pi/tool/web-search/index.ts +2 -0
- package/src/pi/tool/web-search/web-search.ts +127 -0
- package/src/pi/tool/workspace-sandbox.ts +664 -0
- package/src/pi/turn/approval.ts +181 -0
- package/src/pi/turn/index.ts +62 -0
- package/src/pi/turn/tool-recovery.ts +792 -0
- package/src/plugins.ts +1024 -0
- package/src/runtime-agent.ts +654 -0
- package/src/runtime.ts +2880 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
|
+
import {
|
|
3
|
+
PiTranscriptStore,
|
|
4
|
+
appendSessionMessageSync,
|
|
5
|
+
clearSessionSync,
|
|
6
|
+
moveSessionLeafSync,
|
|
7
|
+
} from "./storage";
|
|
8
|
+
|
|
9
|
+
export const piSession = Object.freeze({
|
|
10
|
+
appendMessage: appendSessionMessageSync,
|
|
11
|
+
clear: clearSessionSync,
|
|
12
|
+
/**
|
|
13
|
+
* 建库并返回回合事实源。
|
|
14
|
+
*
|
|
15
|
+
* 这里不再构造 pi 的 `Session` —— 会话所有权归这个 Durable Object,
|
|
16
|
+
* pi 只在需要压缩时被当作纯函数调用。理由见 `PiTranscriptStore` 的注释。
|
|
17
|
+
*/
|
|
18
|
+
create(sql: SqlTaggedTemplate) {
|
|
19
|
+
return { store: new PiTranscriptStore(sql) };
|
|
20
|
+
},
|
|
21
|
+
moveLeaf: moveSessionLeafSync,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export * from "./storage";
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SessionError,
|
|
3
|
+
uuidv7,
|
|
4
|
+
type AgentMessage,
|
|
5
|
+
type CompactionEntry,
|
|
6
|
+
type SessionMetadata,
|
|
7
|
+
type SessionTreeEntry,
|
|
8
|
+
} from "@earendil-works/pi-agent-core";
|
|
9
|
+
import type { SqlTaggedTemplate } from "agents/chat";
|
|
10
|
+
|
|
11
|
+
type EntryRow = {
|
|
12
|
+
id: string;
|
|
13
|
+
body: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type LeafRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
target_id: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function errorCause(error: unknown): Error {
|
|
23
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseEntry(row: EntryRow): SessionTreeEntry {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(row.body) as SessionTreeEntry;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new SessionError(
|
|
31
|
+
"invalid_entry",
|
|
32
|
+
`Invalid SQLite session entry ${row.id}`,
|
|
33
|
+
errorCause(error),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sessionEntryTargetId(entry: SessionTreeEntry): string | null {
|
|
39
|
+
return entry.type === "leaf" || entry.type === "label"
|
|
40
|
+
? entry.targetId
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getEntrySync(
|
|
45
|
+
sql: SqlTaggedTemplate,
|
|
46
|
+
id: string,
|
|
47
|
+
): SessionTreeEntry | undefined {
|
|
48
|
+
const row = sql<EntryRow>`
|
|
49
|
+
SELECT id, body
|
|
50
|
+
FROM pi_session_entries
|
|
51
|
+
WHERE id = ${id}
|
|
52
|
+
`[0];
|
|
53
|
+
return row ? parseEntry(row) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getLeafIdSync(sql: SqlTaggedTemplate): string | null {
|
|
57
|
+
const row = sql<LeafRow>`
|
|
58
|
+
SELECT id, type, target_id
|
|
59
|
+
FROM pi_session_entries
|
|
60
|
+
ORDER BY seq DESC
|
|
61
|
+
LIMIT 1
|
|
62
|
+
`[0];
|
|
63
|
+
if (!row) return null;
|
|
64
|
+
|
|
65
|
+
const leafId = row.type === "leaf" ? row.target_id : row.id;
|
|
66
|
+
if (leafId !== null && !getEntrySync(sql, leafId)) {
|
|
67
|
+
throw new SessionError(
|
|
68
|
+
"invalid_session",
|
|
69
|
+
`Entry ${leafId} not found`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return leafId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function appendEntrySync(
|
|
76
|
+
sql: SqlTaggedTemplate,
|
|
77
|
+
entry: SessionTreeEntry,
|
|
78
|
+
): boolean {
|
|
79
|
+
const before = sql<{ count: number }>`
|
|
80
|
+
SELECT COUNT(*) AS count
|
|
81
|
+
FROM pi_session_entries
|
|
82
|
+
WHERE id = ${entry.id}
|
|
83
|
+
`[0]?.count ?? 0;
|
|
84
|
+
if (before > 0) return false;
|
|
85
|
+
|
|
86
|
+
sql`
|
|
87
|
+
INSERT INTO pi_session_entries (
|
|
88
|
+
id, type, parent_id, target_id, body
|
|
89
|
+
) VALUES (
|
|
90
|
+
${entry.id},
|
|
91
|
+
${entry.type},
|
|
92
|
+
${entry.parentId},
|
|
93
|
+
${sessionEntryTargetId(entry)},
|
|
94
|
+
${JSON.stringify(entry)}
|
|
95
|
+
)
|
|
96
|
+
`;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 给 Durable Object 的 `transactionSync` 调用方用的同步写原语。
|
|
102
|
+
*
|
|
103
|
+
* 必须是同步的:admission 走在 DO 的同步事务里,任何 await 都会破坏那次事务的原子性。
|
|
104
|
+
*/
|
|
105
|
+
export function appendSessionMessageSync(
|
|
106
|
+
sql: SqlTaggedTemplate,
|
|
107
|
+
input: {
|
|
108
|
+
id: string;
|
|
109
|
+
message: AgentMessage;
|
|
110
|
+
submissionId?: string;
|
|
111
|
+
createdAt?: number;
|
|
112
|
+
},
|
|
113
|
+
): boolean {
|
|
114
|
+
const timestamp = new Date(
|
|
115
|
+
input.createdAt ??
|
|
116
|
+
("timestamp" in input.message &&
|
|
117
|
+
typeof input.message.timestamp === "number"
|
|
118
|
+
? input.message.timestamp
|
|
119
|
+
: Date.now()),
|
|
120
|
+
).toISOString();
|
|
121
|
+
const inserted = appendEntrySync(sql, {
|
|
122
|
+
type: "message",
|
|
123
|
+
id: input.id,
|
|
124
|
+
parentId: getLeafIdSync(sql),
|
|
125
|
+
timestamp,
|
|
126
|
+
message: input.message,
|
|
127
|
+
});
|
|
128
|
+
if (inserted && input.submissionId) {
|
|
129
|
+
sql`
|
|
130
|
+
INSERT INTO pi_session_message_submission (
|
|
131
|
+
message_id, submission_id
|
|
132
|
+
) VALUES (
|
|
133
|
+
${input.id}, ${input.submissionId}
|
|
134
|
+
)
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
return inserted;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function moveSessionLeafSync(
|
|
141
|
+
sql: SqlTaggedTemplate,
|
|
142
|
+
targetId: string | null,
|
|
143
|
+
): void {
|
|
144
|
+
if (targetId !== null && !getEntrySync(sql, targetId)) {
|
|
145
|
+
throw new SessionError("not_found", `Entry ${targetId} not found`);
|
|
146
|
+
}
|
|
147
|
+
const id = uuidv7();
|
|
148
|
+
if (!appendEntrySync(sql, {
|
|
149
|
+
type: "leaf",
|
|
150
|
+
id,
|
|
151
|
+
parentId: getLeafIdSync(sql),
|
|
152
|
+
timestamp: new Date().toISOString(),
|
|
153
|
+
targetId,
|
|
154
|
+
})) {
|
|
155
|
+
throw new SessionError(
|
|
156
|
+
"storage",
|
|
157
|
+
`Session entry ${id} already exists`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function clearSessionSync(sql: SqlTaggedTemplate): void {
|
|
163
|
+
sql`DELETE FROM pi_session_message_submission`;
|
|
164
|
+
sql`DELETE FROM pi_session_entries`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 回合事实源:这个 Durable Object 自己拥有的会话记录流。
|
|
169
|
+
*
|
|
170
|
+
* ## 为什么不再 implements pi 的 SessionStorage
|
|
171
|
+
*
|
|
172
|
+
* 我们只用到 branch / entry / appendCompaction 三件事,却曾为此实现一个十方法接口,
|
|
173
|
+
* 并把「一个回合到底长什么样」的定义权交给了 pi 的 **harness 层** —— 而 `SessionStorage`
|
|
174
|
+
* 住在 `dist/harness/types.d.ts`,是 pi 给本地 CLI 用的那一层,不是 pi 内核的必需品
|
|
175
|
+
* (`AgentOptions` 里根本没有 session 字段)。它也是 pi 发版最勤的一层:
|
|
176
|
+
* 0.81.0 已经把 `getPathToRoot` 改名成 `getPathToRootOrCompaction` 并新增两个必填方法,
|
|
177
|
+
* `[Unreleased]` 又要把整个 `SessionStorage` 换成 `SessionRepository` + caller-owned
|
|
178
|
+
* `SessionStore`。**实现别人的接口 = 别人改语义时编译器不会告诉我们。**
|
|
179
|
+
*
|
|
180
|
+
* 现在语义由我们定、写在我们的测试里(见 storage.test.ts 的差分对拍);pi 只作为
|
|
181
|
+
* 纯函数被调用(`prepareCompaction` / `compact` / `buildSessionContext`),
|
|
182
|
+
* 那些改签名编译器一定会报。
|
|
183
|
+
*
|
|
184
|
+
* ## 会话模型(读代码前先看这三条,否则会误改)
|
|
185
|
+
*
|
|
186
|
+
* 1. **leaf 是派生的,不是存的**:seq 最大的那一行;若它是 `leaf` 标记条目,则跟随它的
|
|
187
|
+
* `targetId`。所以**追加任何 entry 都会自动成为新 leaf**,不需要额外「移动」。
|
|
188
|
+
* 2. **回退 leaf 靠追加标记**:`moveSessionLeafSync` append 一条 `leaf` 类型条目指向目标,
|
|
189
|
+
* 表本身始终 append-only,从不改写历史行。
|
|
190
|
+
* 3. **`leaf` 标记永远不会成为任何条目的 parent**:新条目的 parentId 取自
|
|
191
|
+
* `getLeafIdSync()`,那已经是解析过标记之后的真实条目 id。所以按 parentId 上溯时
|
|
192
|
+
* 不会走进标记里。
|
|
193
|
+
*/
|
|
194
|
+
export class PiTranscriptStore {
|
|
195
|
+
constructor(
|
|
196
|
+
private readonly sql: SqlTaggedTemplate,
|
|
197
|
+
metadata?: SessionMetadata,
|
|
198
|
+
) {
|
|
199
|
+
this.sql`
|
|
200
|
+
CREATE TABLE IF NOT EXISTS pi_session_metadata (
|
|
201
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
202
|
+
id TEXT NOT NULL,
|
|
203
|
+
created_at TEXT NOT NULL
|
|
204
|
+
)
|
|
205
|
+
`;
|
|
206
|
+
this.sql`
|
|
207
|
+
CREATE TABLE IF NOT EXISTS pi_session_entries (
|
|
208
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
209
|
+
id TEXT NOT NULL UNIQUE,
|
|
210
|
+
type TEXT NOT NULL,
|
|
211
|
+
parent_id TEXT,
|
|
212
|
+
target_id TEXT,
|
|
213
|
+
body TEXT NOT NULL
|
|
214
|
+
)
|
|
215
|
+
`;
|
|
216
|
+
this.sql`
|
|
217
|
+
CREATE INDEX IF NOT EXISTS pi_session_entries_type
|
|
218
|
+
ON pi_session_entries(type)
|
|
219
|
+
`;
|
|
220
|
+
this.sql`
|
|
221
|
+
CREATE INDEX IF NOT EXISTS pi_session_entries_target
|
|
222
|
+
ON pi_session_entries(type, target_id, seq)
|
|
223
|
+
`;
|
|
224
|
+
this.sql`
|
|
225
|
+
CREATE TABLE IF NOT EXISTS pi_session_message_submission (
|
|
226
|
+
message_id TEXT PRIMARY KEY,
|
|
227
|
+
submission_id TEXT NOT NULL
|
|
228
|
+
)
|
|
229
|
+
`;
|
|
230
|
+
this.sql`
|
|
231
|
+
CREATE INDEX IF NOT EXISTS pi_session_message_submission_id
|
|
232
|
+
ON pi_session_message_submission(submission_id)
|
|
233
|
+
`;
|
|
234
|
+
const initial = metadata ?? {
|
|
235
|
+
id: uuidv7(),
|
|
236
|
+
createdAt: new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
this.sql`
|
|
239
|
+
INSERT OR IGNORE INTO pi_session_metadata (
|
|
240
|
+
singleton, id, created_at
|
|
241
|
+
) VALUES (
|
|
242
|
+
1, ${initial.id}, ${initial.createdAt}
|
|
243
|
+
)
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 从当前 leaf 上溯到 root 的完整路径。
|
|
249
|
+
*
|
|
250
|
+
* 用一条递归 CTE 取全路径。旧实现是 while 循环里每个祖先打一次 SELECT ——
|
|
251
|
+
* canonicalMessages / storedMessages 各调一次,等于每次刷 UI 都是 O(消息数) 次 SQL。
|
|
252
|
+
* JOIN 落在 `id` 上(UNIQUE,有索引),不需要为 parent_id 另建索引。
|
|
253
|
+
*/
|
|
254
|
+
branch(): SessionTreeEntry[] {
|
|
255
|
+
const leafId = getLeafIdSync(this.sql);
|
|
256
|
+
if (leafId === null) return [];
|
|
257
|
+
|
|
258
|
+
const rows = this.sql<EntryRow>`
|
|
259
|
+
WITH RECURSIVE path(id, body, parent_id) AS (
|
|
260
|
+
SELECT id, body, parent_id
|
|
261
|
+
FROM pi_session_entries
|
|
262
|
+
WHERE id = ${leafId}
|
|
263
|
+
UNION ALL
|
|
264
|
+
SELECT e.id, e.body, e.parent_id
|
|
265
|
+
FROM pi_session_entries e
|
|
266
|
+
JOIN path p ON e.id = p.parent_id
|
|
267
|
+
)
|
|
268
|
+
SELECT id, body FROM path
|
|
269
|
+
`;
|
|
270
|
+
if (rows.length === 0) {
|
|
271
|
+
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
|
272
|
+
}
|
|
273
|
+
// CTE 从 leaf 往上产出,这里翻成 root→leaf 与旧的 unshift 顺序一致。
|
|
274
|
+
const path = rows.map(parseEntry).reverse();
|
|
275
|
+
// 断链检测:旧实现在父条目缺失时抛 invalid_session,而 CTE 遇到断点只会静默停止
|
|
276
|
+
// 递归。顶端条目的 parentId 必须为空,否则说明中间有一条祖先不见了。
|
|
277
|
+
const root = path[0];
|
|
278
|
+
if (root?.parentId) {
|
|
279
|
+
throw new SessionError(
|
|
280
|
+
"invalid_session",
|
|
281
|
+
`Entry ${root.parentId} not found`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return path;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
entry(id: string): SessionTreeEntry | undefined {
|
|
288
|
+
return getEntrySync(this.sql, id);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 追加一条压缩条目,整对象写入。
|
|
293
|
+
*
|
|
294
|
+
* 旧路径是把 assembly 侧已经造好的完整 CompactionEntry 拆成 5 个位置参数喂给
|
|
295
|
+
* pi 的 `Session.appendCompaction` —— pi 0.81 把它加到 7 个参数(usage / retainedTail)
|
|
296
|
+
* 之后,多出来的两个会被静默丢掉,编译器不报。整对象写入让这类失败模式在结构上
|
|
297
|
+
* 不存在:调用方给什么就落什么。
|
|
298
|
+
*
|
|
299
|
+
* id / parentId / timestamp 由这里补齐,语义与 pi 的 `appendTypedEntry` 一致
|
|
300
|
+
* (parentId 取当前 leaf;追加后本条自动成为新 leaf)。
|
|
301
|
+
*/
|
|
302
|
+
appendCompaction(
|
|
303
|
+
entry: Omit<CompactionEntry, "id" | "parentId" | "timestamp">,
|
|
304
|
+
): string {
|
|
305
|
+
const id = this.createEntryId();
|
|
306
|
+
const appended = appendEntrySync(this.sql, {
|
|
307
|
+
...entry,
|
|
308
|
+
id,
|
|
309
|
+
parentId: getLeafIdSync(this.sql),
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
});
|
|
312
|
+
if (!appended) {
|
|
313
|
+
throw new SessionError("storage", `Session entry ${id} already exists`);
|
|
314
|
+
}
|
|
315
|
+
return id;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 按类型取条目。生产路径无消费者,但压缩相关测试靠它断言「压缩条目真的落库了」,
|
|
320
|
+
* 是那条行为契约的观测口。
|
|
321
|
+
*/
|
|
322
|
+
findEntries<TType extends SessionTreeEntry["type"]>(
|
|
323
|
+
type: TType,
|
|
324
|
+
): Array<Extract<SessionTreeEntry, { type: TType }>> {
|
|
325
|
+
return this.sql<EntryRow>`
|
|
326
|
+
SELECT id, body
|
|
327
|
+
FROM pi_session_entries
|
|
328
|
+
WHERE type = ${type}
|
|
329
|
+
ORDER BY seq ASC
|
|
330
|
+
`.map(parseEntry) as Array<Extract<SessionTreeEntry, { type: TType }>>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 全量条目(含 leaf 标记),按写入顺序。仅供测试观测整棵树用。 */
|
|
334
|
+
entries(): SessionTreeEntry[] {
|
|
335
|
+
return this.sql<EntryRow>`
|
|
336
|
+
SELECT id, body
|
|
337
|
+
FROM pi_session_entries
|
|
338
|
+
ORDER BY seq ASC
|
|
339
|
+
`.map(parseEntry);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 短 id,与 pi 的 `createEntryId` 同规则(uuidv7 尾 8 位 + 碰撞重试)。
|
|
344
|
+
* 必须保持一致:压缩条目的 firstKeptEntryId 引用的就是这种 id。
|
|
345
|
+
*/
|
|
346
|
+
private createEntryId(): string {
|
|
347
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
348
|
+
const id = uuidv7().slice(-8);
|
|
349
|
+
if (!getEntrySync(this.sql, id)) return id;
|
|
350
|
+
}
|
|
351
|
+
return uuidv7();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 本文件把 ai-sdk Tool 转成 Pi AgentTool。
|
|
2
|
+
// `@cloudflare/think`、Agents Skills 和 Workspace 等上游工厂生成 ai-sdk Tool 时调用这个边界。
|
|
3
|
+
// 转换只保留在这里,才能让后续治理和结算始终看到同一种 AgentTool 结果;核心术语见 `../../index.ts` 入口说明。
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AgentTool,
|
|
7
|
+
AgentToolResult,
|
|
8
|
+
} from "@earendil-works/pi-agent-core";
|
|
9
|
+
import { asSchema, type Tool as AiTool } from "ai";
|
|
10
|
+
import { serializeOutput } from "../../lib/artifacts";
|
|
11
|
+
|
|
12
|
+
/** 控制一个 ai-sdk Tool 如何投影到 Pi AgentTool。 */
|
|
13
|
+
export interface AiToolAdapterOptions {
|
|
14
|
+
/** AgentTool requires a label; ai-sdk tools have no equivalent field. */
|
|
15
|
+
readonly label?: string;
|
|
16
|
+
/** Overrides the upstream description (used for `[ext] ` prefixes). */
|
|
17
|
+
readonly description?: string;
|
|
18
|
+
/** Wraps the raw ai-sdk return value before it becomes `details`. */
|
|
19
|
+
readonly details?: (output: unknown) => unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 把 ai-sdk Tool 的静态或动态 description 解析成文本。
|
|
23
|
+
// `aiToolToPi()` 在没有显式 description 覆盖时调用它。
|
|
24
|
+
// 当前 ai@7 允许 description 是函数,所以适配器在无请求上下文中安全求值,失败时退回空文本而不阻断工具组装。
|
|
25
|
+
function resolveDescription(tool: AiTool): string {
|
|
26
|
+
const raw = (tool as { description?: unknown }).description;
|
|
27
|
+
if (typeof raw === "function") {
|
|
28
|
+
try {
|
|
29
|
+
return String(
|
|
30
|
+
(raw as (options: { context: undefined }) => unknown)({
|
|
31
|
+
context: undefined,
|
|
32
|
+
}) ?? "",
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return typeof raw === "string" ? raw : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 把一个可执行的 ai-sdk Tool 转成 Pi AgentTool。
|
|
43
|
+
*
|
|
44
|
+
* Workspace、Skill 和其他 ai-sdk 工具工厂在把候选项交给 `compilePiTools()` 前调用。
|
|
45
|
+
*
|
|
46
|
+
* 实现必须在一处同时对齐 schema、execute 参数和结果形状,否则 Pi 的校验、取消信号和 artifact 计量会绕过统一工具边界。
|
|
47
|
+
*
|
|
48
|
+
* @remarks `inputSchema` 经 ai@7 `asSchema()` 转为 JSON Schema;原始返回值保留在 `details` 中,供 `resultPressure()` 识别 `artifact_ref`。
|
|
49
|
+
*/
|
|
50
|
+
export function aiToolToPi(
|
|
51
|
+
name: string,
|
|
52
|
+
tool: AiTool,
|
|
53
|
+
options: AiToolAdapterOptions = {},
|
|
54
|
+
): AgentTool<any, unknown> {
|
|
55
|
+
const inputSchema = (tool as { inputSchema?: unknown }).inputSchema;
|
|
56
|
+
const parameters = asSchema(
|
|
57
|
+
inputSchema as Parameters<typeof asSchema>[0],
|
|
58
|
+
).jsonSchema as AgentTool["parameters"];
|
|
59
|
+
const execute = (tool as { execute?: unknown }).execute;
|
|
60
|
+
if (typeof execute !== "function") {
|
|
61
|
+
throw new Error(`ai tool is not executable: ${name}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
label: options.label
|
|
67
|
+
?? (tool as { title?: string }).title
|
|
68
|
+
?? name,
|
|
69
|
+
description: options.description ?? resolveDescription(tool),
|
|
70
|
+
parameters,
|
|
71
|
+
// 用 Pi 的调用签名执行底层 ai-sdk Tool 并返回 AgentToolResult。
|
|
72
|
+
// Pi 工具循环选中这个适配后的工具时调用,参数和 AbortSignal 被转交到 ai-sdk execute 上下文。
|
|
73
|
+
// ai-sdk 结果既要序列化成模型可见文本又要原样保留 details,不能只返回其中一份。
|
|
74
|
+
async execute(
|
|
75
|
+
toolCallId: string,
|
|
76
|
+
params: unknown,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
): Promise<AgentToolResult<unknown>> {
|
|
79
|
+
const raw = await (execute as (
|
|
80
|
+
input: unknown,
|
|
81
|
+
context: {
|
|
82
|
+
toolCallId: string;
|
|
83
|
+
messages: never[];
|
|
84
|
+
abortSignal?: AbortSignal;
|
|
85
|
+
context: undefined;
|
|
86
|
+
},
|
|
87
|
+
) => unknown)(params, {
|
|
88
|
+
toolCallId,
|
|
89
|
+
messages: [],
|
|
90
|
+
abortSignal: signal,
|
|
91
|
+
context: undefined,
|
|
92
|
+
});
|
|
93
|
+
const details = options.details ? options.details(raw) : raw;
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: serializeOutput(details).text }],
|
|
96
|
+
details,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentTool,
|
|
3
|
+
AgentToolResult,
|
|
4
|
+
} from "@earendil-works/pi-agent-core";
|
|
5
|
+
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
6
|
+
import { serializeOutput } from "../../lib/artifacts";
|
|
7
|
+
import type { PiToolCandidate } from "./compiler";
|
|
8
|
+
import { webSearchPiToolCandidate } from "./web-search";
|
|
9
|
+
import type { WebSearch } from "./web-search/api";
|
|
10
|
+
|
|
11
|
+
const askUserParameters = Type.Object({
|
|
12
|
+
question: Type.String({
|
|
13
|
+
description: "The question to put to the user.",
|
|
14
|
+
}),
|
|
15
|
+
options: Type.Array(Type.String(), {
|
|
16
|
+
minItems: 2,
|
|
17
|
+
maxItems: 6,
|
|
18
|
+
description: "2-6 mutually distinct options for the user to choose from.",
|
|
19
|
+
}),
|
|
20
|
+
multiSelect: Type.Optional(Type.Boolean({
|
|
21
|
+
description:
|
|
22
|
+
"Whether the user may select more than one option. Defaults to false.",
|
|
23
|
+
})),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const suggestFollowupsParameters = Type.Object({
|
|
27
|
+
items: Type.Array(Type.String({
|
|
28
|
+
description: "A follow-up the user could ask next, phrased as a request.",
|
|
29
|
+
}), {
|
|
30
|
+
minItems: 2,
|
|
31
|
+
maxItems: 4,
|
|
32
|
+
description: "2-4 concrete, distinct follow-up directions.",
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const updatePlanParameters = Type.Object({
|
|
37
|
+
steps: Type.Array(Type.Object({
|
|
38
|
+
text: Type.String({
|
|
39
|
+
description: "The step, phrased as a short imperative.",
|
|
40
|
+
}),
|
|
41
|
+
status: Type.Union([
|
|
42
|
+
Type.Literal("pending"),
|
|
43
|
+
Type.Literal("in_progress"),
|
|
44
|
+
Type.Literal("done"),
|
|
45
|
+
], { description: "Current status of this step." }),
|
|
46
|
+
}), {
|
|
47
|
+
description:
|
|
48
|
+
"The complete, ordered plan. Overwrites any previously reported plan.",
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function result<T>(details: T): AgentToolResult<T> {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: serializeOutput(details).text }],
|
|
55
|
+
details,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidate {
|
|
60
|
+
return {
|
|
61
|
+
owner: "runtime-base",
|
|
62
|
+
authorized: true,
|
|
63
|
+
requiredExecutionLevel: "safe",
|
|
64
|
+
source: "action",
|
|
65
|
+
tool,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Runtime-permanent Pi tools, independent of optional Host plugins. */
|
|
70
|
+
export function basePiToolCandidates(
|
|
71
|
+
webSearch: WebSearch,
|
|
72
|
+
): PiToolCandidate[] {
|
|
73
|
+
return [
|
|
74
|
+
candidate({
|
|
75
|
+
name: "ask_user",
|
|
76
|
+
label: "Ask user",
|
|
77
|
+
description:
|
|
78
|
+
"Ask the user to choose among a small set of options. Call this tool whenever you need the user to make a choice from limited options — do NOT write plain text like 'please pick A / B / C'. After calling this tool, end your turn and wait for the user's reply.",
|
|
79
|
+
parameters: askUserParameters,
|
|
80
|
+
async execute() {
|
|
81
|
+
return result({ asked: true });
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
candidate({
|
|
85
|
+
name: "suggest_followups",
|
|
86
|
+
label: "Suggest follow-ups",
|
|
87
|
+
description:
|
|
88
|
+
"Offer the user 2-4 optional follow-up directions after completing a substantive task (a report, an analysis, a multi-step job). Call this at most once, and it MUST be the very last thing you do: finish all of your prose FIRST, then call this tool and END the turn immediately — do NOT write any text after calling it. Do NOT call it for small talk, quick answers, or while a task is still in progress.",
|
|
89
|
+
parameters: suggestFollowupsParameters,
|
|
90
|
+
async execute(_toolCallId, input) {
|
|
91
|
+
return result({ noted: true, count: input.items.length });
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
candidate({
|
|
95
|
+
name: "update_plan",
|
|
96
|
+
label: "Update plan",
|
|
97
|
+
description:
|
|
98
|
+
"Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
|
|
99
|
+
parameters: updatePlanParameters,
|
|
100
|
+
async execute(_toolCallId, input) {
|
|
101
|
+
return result({
|
|
102
|
+
ok: true,
|
|
103
|
+
total: input.steps.length,
|
|
104
|
+
done: input.steps.filter((step) => step.status === "done").length,
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
webSearchPiToolCandidate(webSearch),
|
|
109
|
+
];
|
|
110
|
+
}
|