@sigrun/sigrun 0.4.2 → 0.5.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/CHANGELOG.md +37 -1
- package/README.md +46 -23
- package/dist/commands/expand.js +37 -5
- package/dist/curation/attribution.js +135 -0
- package/dist/curation/deletion.js +32 -0
- package/dist/curation/gate.js +50 -0
- package/dist/curation/host.js +342 -0
- package/dist/curation/internal.js +54 -0
- package/dist/curation/names.js +5 -0
- package/dist/curation/overlays.js +325 -0
- package/dist/curation/paths.js +17 -0
- package/dist/curation/run-doc.js +64 -0
- package/dist/curation/runs.js +579 -0
- package/dist/curation/scan.js +30 -0
- package/dist/curation/skill-tool.js +308 -0
- package/dist/curation/store.js +444 -0
- package/dist/curation/targets.js +77 -0
- package/dist/curation/usage.js +124 -0
- package/dist/curation/wrap.js +212 -0
- package/dist/extension.d.ts +282 -0
- package/dist/harness/boot.js +209 -9
- package/dist/harness/builtins.js +12 -9
- package/dist/harness/event-bus.js +64 -0
- package/dist/harness/loader.js +114 -1
- package/dist/harness/naming.js +6 -0
- package/dist/harness/packages.js +14 -9
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +139 -26
- package/dist/harness/settings.js +2 -2
- package/dist/harness/tools.js +70 -0
- package/dist/jobs/fire.js +33 -11
- package/dist/jobs/skills-refs.js +40 -0
- package/dist/sandbox/policy.js +14 -0
- package/dist/server/blocks/answers.js +9 -3
- package/dist/server/conversation-ops.js +21 -3
- package/dist/server/helpers.js +1 -1
- package/dist/server/host-bridge.js +6 -1
- package/dist/server/notifications.js +6 -0
- package/dist/server/routes/meta.js +8 -3
- package/dist/server/routes/submissions.js +5 -0
- package/dist/server/routes/surfaces.js +3 -1
- package/dist/server/routes/users.js +7 -0
- package/dist/server/schema-fixture.js +168 -0
- package/dist/server/session-meta.js +12 -10
- package/dist/settings/core-sections.js +9 -0
- package/dist/settings/registry.js +6 -0
- package/dist/skills/index.js +88 -19
- package/node_modules/@sigrun/shared/CHANGELOG.md +3 -1
- package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
- package/node_modules/@sigrun/shared/dist/access.js +8 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +9 -7
- package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
- package/webui/dist/index.html +1 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator run driver (curator P6, spec §6.5): the host-side core
|
|
3
|
+
* behind `host.curation.forkReview` and `host.curation.digestRun`.
|
|
4
|
+
*
|
|
5
|
+
* A curator run is ONE submission on ONE internal conversation whose
|
|
6
|
+
* creating commit carries the run doc (`CurationRunDoc` — A6) and the
|
|
7
|
+
* internal marker (`markInternal` — A2), whose agent selects the gate
|
|
8
|
+
* (`sigrun.curation-gate` — A8), and whose every mutating tool call
|
|
9
|
+
* executes under the store-user/run-allow discipline A6/A8 built. This
|
|
10
|
+
* module creates that conversation (a fork of the reviewed parent, or a
|
|
11
|
+
* fresh digest conversation) and DRIVES the run:
|
|
12
|
+
*
|
|
13
|
+
* - aborts at `maxToolCalls` TOOL RESULTS (the budget counts committed
|
|
14
|
+
* results, not offered calls — a blocked call is free);
|
|
15
|
+
* - aborts at `deadlineMs` (wall clock from the producer's start);
|
|
16
|
+
* - aborts when the caller's `signal` fires;
|
|
17
|
+
* - aborts when the PARENT's owner, sharing, head (reset/compaction)
|
|
18
|
+
* or admission state changes from the values captured at start —
|
|
19
|
+
* the review reviews the conversation it saw (→ `cancelled`);
|
|
20
|
+
* - collects `details.curation` from the run's tool results into
|
|
21
|
+
* `changes` (tool-result order), and the final assistant text into
|
|
22
|
+
* `text`;
|
|
23
|
+
* - stamps the `reviewId` on every feed entry the run causes — through
|
|
24
|
+
* the run doc, which A6's tool reads for exactly that stamp.
|
|
25
|
+
*
|
|
26
|
+
* The agent rebuild (fork reviews) follows the cache-parity spike
|
|
27
|
+
* exactly: rebuilt from the parent's STORED `pi.agent` — model,
|
|
28
|
+
* thinkingLevel (explicit), extensions as `{name}` objects plus the
|
|
29
|
+
* gate, tools, modelTools (`agent.modelTools ?? undefined`) — which
|
|
30
|
+
* gives a byte-identical prefix (a section change costs exactly one
|
|
31
|
+
* `pi.system` tail patch and still hits the cache fully).
|
|
32
|
+
*
|
|
33
|
+
* `maxOutputTokens` is an ADVISORY bound: pi-durable 1.4.0 exposes no
|
|
34
|
+
* per-submission output cap (no `maxTokens` on the stream options), so
|
|
35
|
+
* the driver cannot hand it to the provider. It is validated and
|
|
36
|
+
* recorded; the wall-clock and tool-call budgets remain the enforced
|
|
37
|
+
* limits. (Deviation recorded in the A9 report — never silent.)
|
|
38
|
+
*/
|
|
39
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
40
|
+
import { AgentDoc, AssistantEntry, LiveDoc, ToolResultEntry, } from "@earendil-works/pi-durable";
|
|
41
|
+
import { ensureRequestId, recordAuthor } from "./attribution.js";
|
|
42
|
+
import { isInternal, markInternal } from "./internal.js";
|
|
43
|
+
import { curationRun, CurationRunDoc } from "./run-doc.js";
|
|
44
|
+
import { CURATION_GATE_EXTENSION } from "./gate.js";
|
|
45
|
+
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
46
|
+
/** The target-id resolver for collected changes (the host installs it). */
|
|
47
|
+
let targetOfTool = (tool) => tool === "skill_manage" ? "sigrun.skills" : `tool:${tool}`;
|
|
48
|
+
/** The host installs the registry-backed resolver (the qualified ids). */
|
|
49
|
+
export function setChangeTargetResolver(resolver) {
|
|
50
|
+
targetOfTool = resolver;
|
|
51
|
+
}
|
|
52
|
+
/** Validate the shared RunOpts invariants (the producers' fail-loud gate). */
|
|
53
|
+
export function checkRunOpts(opts) {
|
|
54
|
+
if (opts.allow.length === 0) {
|
|
55
|
+
throw new Error("a curator run requires a non-empty allow list — the gate's block message names the allowed tools");
|
|
56
|
+
}
|
|
57
|
+
if (opts.maxToolCalls < 1) {
|
|
58
|
+
throw new Error("maxToolCalls must be at least 1");
|
|
59
|
+
}
|
|
60
|
+
if (opts.maxOutputTokens < 1) {
|
|
61
|
+
throw new Error("maxOutputTokens must be at least 1");
|
|
62
|
+
}
|
|
63
|
+
if (opts.deadlineMs < 1) {
|
|
64
|
+
throw new Error("deadlineMs must be at least 1");
|
|
65
|
+
}
|
|
66
|
+
if (opts.reviewId === "") {
|
|
67
|
+
throw new Error("reviewId must not be empty");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** A `{name}` object (the AgentChange member spelling for extensions/tools). */
|
|
71
|
+
function named(name) {
|
|
72
|
+
return { name };
|
|
73
|
+
}
|
|
74
|
+
/** SAFETY: pi-durable's applyChange reads only `item.name` from each member
|
|
75
|
+
* of an extensions/tools/modelTools array (harness/agent.js `names()`), so a
|
|
76
|
+
* bare `{name}` object satisfies every field the store ever reads; the cast
|
|
77
|
+
* only bridges the compile-time ToolRegistration shape. */
|
|
78
|
+
function namedTools(names) {
|
|
79
|
+
// SAFETY: cast — the store reads only `name` (see the comment above).
|
|
80
|
+
return names.map(named);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The cache-parity agent rebuild: the parent's stored `pi.agent` with
|
|
84
|
+
* the gate appended to the extension selection. Every field is rebuilt
|
|
85
|
+
* from the STORED state (the spike's recipe) — model, thinkingLevel
|
|
86
|
+
* (explicit), extensions as `{name}` objects (plus the gate), tools,
|
|
87
|
+
* modelTools — so the fork's prompt prefix is byte-identical to the
|
|
88
|
+
* parent's except the gate's (hooks-only, no sections) contribution.
|
|
89
|
+
*/
|
|
90
|
+
export function rebuildAgentWithGate(stored) {
|
|
91
|
+
const gate = named(CURATION_GATE_EXTENSION);
|
|
92
|
+
const extensions = stored?.extensions;
|
|
93
|
+
let rebuiltExtensions;
|
|
94
|
+
if (extensions === undefined || extensions === null) {
|
|
95
|
+
// An unset selection follows the host default; an {add} edit adds
|
|
96
|
+
// the gate WITHOUT pinning the default (a pin would freeze a
|
|
97
|
+
// later default change into this run).
|
|
98
|
+
rebuiltExtensions = { add: [gate] };
|
|
99
|
+
}
|
|
100
|
+
else if (Array.isArray(extensions)) {
|
|
101
|
+
rebuiltExtensions = [
|
|
102
|
+
...extensions.map(named),
|
|
103
|
+
gate,
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const edit = extensions;
|
|
108
|
+
rebuiltExtensions = {
|
|
109
|
+
...(edit.add === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: { add: [...edit.add.map(named), gate] }),
|
|
112
|
+
...(edit.remove === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { remove: edit.remove.map(named) }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const tools = stored?.tools;
|
|
118
|
+
const modelTools = stored?.modelTools;
|
|
119
|
+
return {
|
|
120
|
+
...(stored?.model === undefined || stored?.model === null
|
|
121
|
+
? {}
|
|
122
|
+
: { model: stored.model }),
|
|
123
|
+
...(stored?.thinkingLevel === undefined || stored?.thinkingLevel === null
|
|
124
|
+
? {}
|
|
125
|
+
: {
|
|
126
|
+
thinkingLevel: stored.thinkingLevel,
|
|
127
|
+
}),
|
|
128
|
+
extensions: rebuiltExtensions,
|
|
129
|
+
...(tools === undefined || tools === null
|
|
130
|
+
? {}
|
|
131
|
+
: Array.isArray(tools)
|
|
132
|
+
? { tools: namedTools(tools) }
|
|
133
|
+
: {
|
|
134
|
+
tools: {
|
|
135
|
+
remove: namedTools(tools.remove),
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
...(modelTools === undefined || modelTools === null
|
|
139
|
+
? {}
|
|
140
|
+
: Array.isArray(modelTools)
|
|
141
|
+
? { modelTools: namedTools(modelTools) }
|
|
142
|
+
: {
|
|
143
|
+
modelTools: {
|
|
144
|
+
...(modelTools.remove === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: {
|
|
147
|
+
remove: namedTools(modelTools.remove),
|
|
148
|
+
}),
|
|
149
|
+
...(modelTools.add === undefined
|
|
150
|
+
? {}
|
|
151
|
+
: {
|
|
152
|
+
add: namedTools(modelTools.add),
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** The parent's captured state (the cancel oracle's start values). */
|
|
159
|
+
async function captureParent(harness, id, ctx) {
|
|
160
|
+
const meta = await harness.snapshot(SessionMetaDoc, id, ctx);
|
|
161
|
+
const conversation = await harness.conversation(id, ctx);
|
|
162
|
+
const newest = conversation === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: await newestEntry(conversation, ctx);
|
|
165
|
+
let busy = false;
|
|
166
|
+
try {
|
|
167
|
+
const live = await harness.snapshot(LiveDoc, id, ctx);
|
|
168
|
+
busy = live?.run !== undefined;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
busy = false;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
owner: meta?.owner,
|
|
175
|
+
sharing: meta?.sharing,
|
|
176
|
+
head: newest,
|
|
177
|
+
busy,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** The text blocks of one assistant entry's message, joined. */
|
|
181
|
+
function assistantText(entry) {
|
|
182
|
+
let text = "";
|
|
183
|
+
for (const message of entry.model ?? []) {
|
|
184
|
+
if (message.role !== "assistant")
|
|
185
|
+
continue;
|
|
186
|
+
// SAFETY: an assistant entry's model array is `[AssistantMessage]`
|
|
187
|
+
// (pi-durable's entries contract); the structural read below only
|
|
188
|
+
// touches role/content, which every Message variant carries.
|
|
189
|
+
const content = message.content;
|
|
190
|
+
if (typeof content === "string") {
|
|
191
|
+
text += content;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const block of content) {
|
|
195
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
196
|
+
text += block.text;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return text;
|
|
201
|
+
}
|
|
202
|
+
/** Whether `id` is strictly after `anchor` in the session's ascending id order. */
|
|
203
|
+
function isAfter(id, anchor) {
|
|
204
|
+
if (anchor === undefined)
|
|
205
|
+
return true;
|
|
206
|
+
return id > anchor;
|
|
207
|
+
}
|
|
208
|
+
/** A stable comparison key for the sharing state. */
|
|
209
|
+
function sharingKey(sharing) {
|
|
210
|
+
return sharing === undefined ? "" : JSON.stringify(sharing);
|
|
211
|
+
}
|
|
212
|
+
/** The newest visible entry of one conversation, or undefined. */
|
|
213
|
+
async function newestEntry(conversation, ctx) {
|
|
214
|
+
const page = await conversation.entries({}, 1, undefined, ctx);
|
|
215
|
+
return page.items[0]?.id;
|
|
216
|
+
}
|
|
217
|
+
/** The failed/"not eligible" shape. */
|
|
218
|
+
function notEligible(why) {
|
|
219
|
+
return {
|
|
220
|
+
status: "failed",
|
|
221
|
+
text: "",
|
|
222
|
+
changes: [],
|
|
223
|
+
error: `not eligible: ${why}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** The result of one stopped run, classified. */
|
|
227
|
+
function stoppedResult(reason) {
|
|
228
|
+
switch (reason.kind) {
|
|
229
|
+
case "deadline":
|
|
230
|
+
return {
|
|
231
|
+
status: "deadline",
|
|
232
|
+
text: "",
|
|
233
|
+
changes: [],
|
|
234
|
+
error: "the run exceeded its deadline",
|
|
235
|
+
};
|
|
236
|
+
case "signal":
|
|
237
|
+
return {
|
|
238
|
+
status: "cancelled",
|
|
239
|
+
text: "",
|
|
240
|
+
changes: [],
|
|
241
|
+
error: "the caller's signal fired",
|
|
242
|
+
};
|
|
243
|
+
case "maxToolCalls":
|
|
244
|
+
return {
|
|
245
|
+
status: "cancelled",
|
|
246
|
+
text: "",
|
|
247
|
+
changes: [],
|
|
248
|
+
error: "the run exceeded its maxToolCalls budget",
|
|
249
|
+
};
|
|
250
|
+
case "parent":
|
|
251
|
+
return {
|
|
252
|
+
status: "cancelled",
|
|
253
|
+
text: "",
|
|
254
|
+
changes: [],
|
|
255
|
+
error: `the parent changed under review: ${reason.what}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Drive one curator run: submit `opts.instruction` on the prepared run
|
|
261
|
+
* conversation and supervise it to settlement under the RunOpts bounds.
|
|
262
|
+
* `prepare` resolves the run conversation (fork or fresh); `parent`
|
|
263
|
+
* (fork reviews) is watched for drift.
|
|
264
|
+
*/
|
|
265
|
+
export async function driveRun(deps, prepare, opts, parent) {
|
|
266
|
+
const { harness } = deps;
|
|
267
|
+
// The stop lane: one promise; the first cause to fire aborts the run
|
|
268
|
+
// and classifies the result.
|
|
269
|
+
let stopped;
|
|
270
|
+
let releaseStop;
|
|
271
|
+
const stoppedPromise = new Promise((resolve) => {
|
|
272
|
+
releaseStop = resolve;
|
|
273
|
+
});
|
|
274
|
+
const stop = (reason) => {
|
|
275
|
+
if (stopped !== undefined)
|
|
276
|
+
return;
|
|
277
|
+
stopped = reason;
|
|
278
|
+
releaseStop?.();
|
|
279
|
+
};
|
|
280
|
+
const timer = setTimeout(() => stop({ kind: "deadline" }), opts.deadlineMs);
|
|
281
|
+
const onAbort = () => stop({ kind: "signal" });
|
|
282
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
283
|
+
// The commit watcher: publications carry the entry/submission/doc
|
|
284
|
+
// records; two watches run in ONE listener — (a) this run's tool
|
|
285
|
+
// results (the maxToolCalls budget), and (b) the PARENT's drift.
|
|
286
|
+
// Both are synchronous reads of the publication's own changes (the
|
|
287
|
+
// subscribeCommits listener contract: no blocking, no Session calls).
|
|
288
|
+
//
|
|
289
|
+
// Parent drift, precisely: an ENTRY change on the parent beyond the
|
|
290
|
+
// start-captured head (a reset/compaction marker or a newly admitted
|
|
291
|
+
// run's user entry — "the conversation was reset or compacted" /
|
|
292
|
+
// "a new run was admitted"), a SUBMISSION change on the parent (the
|
|
293
|
+
// admission state moving), or a SESSION-META DOCUMENT change on the
|
|
294
|
+
// parent (owner or sharing). Entry ids ascend, so `id > start.head`
|
|
295
|
+
// is the beyond-the-captured-head test; a document change reads the
|
|
296
|
+
// kind alone (the read of the value is still the publication's own
|
|
297
|
+
// committed record — no Session API).
|
|
298
|
+
let toolResults = 0;
|
|
299
|
+
// The parent's start state, captured BEFORE the fork exists (the
|
|
300
|
+
// fork's own creating commit touches the parent's nothing — a fork
|
|
301
|
+
// writes only the NEW conversation's rows).
|
|
302
|
+
const start = parent === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: await captureParent(harness, parent, BACKGROUND_CONTEXT);
|
|
305
|
+
// The run conversation's id — bound after `prepare` resolves; the
|
|
306
|
+
// watcher below reads it live so the budget count starts at the
|
|
307
|
+
// run's FIRST tool result and never sees the parent's own rows.
|
|
308
|
+
let runId;
|
|
309
|
+
let watcher;
|
|
310
|
+
const watch = () => {
|
|
311
|
+
watcher?.();
|
|
312
|
+
watcher = harness.subscribeCommits((publication) => {
|
|
313
|
+
for (const change of publication.changes) {
|
|
314
|
+
if (change.type === "entry") {
|
|
315
|
+
if (runId !== undefined &&
|
|
316
|
+
change.value.conversationId === runId &&
|
|
317
|
+
change.value.kind === ToolResultEntry.kind) {
|
|
318
|
+
toolResults += 1;
|
|
319
|
+
if (toolResults >= opts.maxToolCalls) {
|
|
320
|
+
stop({ kind: "maxToolCalls" });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (parent !== undefined && start !== undefined) {
|
|
324
|
+
if (change.value.conversationId === parent &&
|
|
325
|
+
change.value.id !== start.head &&
|
|
326
|
+
isAfter(change.value.id, start.head)) {
|
|
327
|
+
stop({
|
|
328
|
+
kind: "parent",
|
|
329
|
+
what: change.value.kind === "pi.reset"
|
|
330
|
+
? "the conversation was reset or compacted"
|
|
331
|
+
: "a new run was admitted",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (parent === undefined || start === undefined)
|
|
338
|
+
continue;
|
|
339
|
+
if (change.type === "submission") {
|
|
340
|
+
if (change.value.conversationId === parent) {
|
|
341
|
+
stop({ kind: "parent", what: "a new run was admitted" });
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (change.type === "document") {
|
|
346
|
+
if (change.conversationId === parent &&
|
|
347
|
+
change.record.kind === "sigrun.session-meta" &&
|
|
348
|
+
change.value !== null) {
|
|
349
|
+
// Compare the COMMITTED VALUE's owner/sharing fields —
|
|
350
|
+
// the activity stamper writes `lastActivityAt` on the
|
|
351
|
+
// same doc after every settle (a benign change that
|
|
352
|
+
// must never cancel a review). SAFETY: the value is
|
|
353
|
+
// the adopted JSON revision; the structural read
|
|
354
|
+
// touches two optional fields only.
|
|
355
|
+
const value = change.value;
|
|
356
|
+
if (value.owner !== start.owner ||
|
|
357
|
+
sharingKey(value.sharing) !== sharingKey(start.sharing)) {
|
|
358
|
+
stop({
|
|
359
|
+
kind: "parent",
|
|
360
|
+
what: "the parent's owner or sharing changed",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
watch();
|
|
369
|
+
const cleanup = () => {
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
372
|
+
watcher?.();
|
|
373
|
+
watcher = undefined;
|
|
374
|
+
};
|
|
375
|
+
try {
|
|
376
|
+
const conversation = await prepare();
|
|
377
|
+
runId = conversation.id;
|
|
378
|
+
// The run's instruction is an input like any other (the A1 pin
|
|
379
|
+
// applies): it carries a requestId and records the curated user as
|
|
380
|
+
// its author on both keys, so the run's own transcript attributes
|
|
381
|
+
// consistently with user submissions.
|
|
382
|
+
// The run's user (the creating commit wrote the run doc): the
|
|
383
|
+
// instruction is submitted on their behalf, so it records them as
|
|
384
|
+
// author on both keys exactly like a user input (A1's pin).
|
|
385
|
+
const run = await curationRun(harness, conversation.id, BACKGROUND_CONTEXT);
|
|
386
|
+
const requestId = ensureRequestId();
|
|
387
|
+
if (run?.user !== undefined) {
|
|
388
|
+
await recordAuthor(conversation, { requestId }, run.user, BACKGROUND_CONTEXT);
|
|
389
|
+
}
|
|
390
|
+
const submission = await conversation.submit({ type: "input", content: opts.instruction, requestId }, BACKGROUND_CONTEXT);
|
|
391
|
+
if (run?.user !== undefined) {
|
|
392
|
+
await recordAuthor(conversation, { submissionId: submission.id }, run.user, BACKGROUND_CONTEXT);
|
|
393
|
+
}
|
|
394
|
+
// Race the settlement against the stop lane. The wait's own
|
|
395
|
+
// context is never cancelled (the run conversation gets a real
|
|
396
|
+
// abort below instead), so an aborted settlement can only mean the
|
|
397
|
+
// stop lane fired first.
|
|
398
|
+
const settled = await Promise.race([
|
|
399
|
+
submission.wait(BACKGROUND_CONTEXT).then((record) => ({ record }), () => undefined),
|
|
400
|
+
stoppedPromise.then(() => undefined),
|
|
401
|
+
]);
|
|
402
|
+
if (settled === undefined) {
|
|
403
|
+
// The stop lane won (or the wait itself failed): abort the run
|
|
404
|
+
// conversation, then classify. The abort withdraws the queued
|
|
405
|
+
// input and marks the live task; the internal conversation is
|
|
406
|
+
// never listed, so nothing else observes the partial run.
|
|
407
|
+
await conversation.abort(BACKGROUND_CONTEXT).catch(() => undefined);
|
|
408
|
+
return stoppedResult(stopped ?? { kind: "deadline" });
|
|
409
|
+
}
|
|
410
|
+
const record = settled.record;
|
|
411
|
+
if (record.status === "unanswered") {
|
|
412
|
+
return {
|
|
413
|
+
status: "failed",
|
|
414
|
+
text: "",
|
|
415
|
+
changes: [],
|
|
416
|
+
error: `the run ended unanswered: ${record.reason}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const changes = await collectChanges(conversation);
|
|
420
|
+
const text = await answerText(conversation, record.answer);
|
|
421
|
+
return { status: "completed", text, changes };
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return {
|
|
425
|
+
status: "failed",
|
|
426
|
+
text: "",
|
|
427
|
+
changes: [],
|
|
428
|
+
error: error instanceof Error ? error.message : String(error),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
cleanup();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Collect the run's `details.curation` changes, in entry order. */
|
|
436
|
+
async function collectChanges(conversation) {
|
|
437
|
+
const page = await conversation.entries({ order: "ascending" }, 500, undefined, BACKGROUND_CONTEXT);
|
|
438
|
+
const changes = [];
|
|
439
|
+
for (const entry of page.items) {
|
|
440
|
+
if (entry.kind !== ToolResultEntry.kind)
|
|
441
|
+
continue;
|
|
442
|
+
for (const message of entry.model ?? []) {
|
|
443
|
+
if (message.role !== "toolResult")
|
|
444
|
+
continue;
|
|
445
|
+
const details = message.details;
|
|
446
|
+
const curation = details?.curation;
|
|
447
|
+
if (curation === undefined ||
|
|
448
|
+
typeof curation.changeId !== "string" ||
|
|
449
|
+
typeof curation.summary !== "string") {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
changes.push({
|
|
453
|
+
changeId: curation.changeId,
|
|
454
|
+
summary: curation.summary,
|
|
455
|
+
target: targetOfTool(message.toolName),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return changes;
|
|
460
|
+
}
|
|
461
|
+
/** The run's answer entry text (the settlement's answer, or ""). */
|
|
462
|
+
async function answerText(conversation, answer) {
|
|
463
|
+
if (answer === undefined)
|
|
464
|
+
return "";
|
|
465
|
+
// The answer entry is an assistant entry: read it through the
|
|
466
|
+
// conversation's own entry query (maxEntryId lands the answer).
|
|
467
|
+
const page = await conversation.entries({ maxEntryId: answer }, 1, undefined, BACKGROUND_CONTEXT);
|
|
468
|
+
const entry = page.items[0];
|
|
469
|
+
if (entry === undefined || entry.id !== answer)
|
|
470
|
+
return "";
|
|
471
|
+
void AssistantEntry;
|
|
472
|
+
return assistantText(entry);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The fork-review producer: eligibility checks, the fork with the
|
|
476
|
+
* cache-parity agent rebuild, and the run doc in the creating commit.
|
|
477
|
+
* Returns the failed/"not eligible" shape without creating anything
|
|
478
|
+
* when the parent refuses.
|
|
479
|
+
*/
|
|
480
|
+
export async function forkReviewRun(deps, id, opts) {
|
|
481
|
+
checkRunOpts(opts);
|
|
482
|
+
const { harness } = deps;
|
|
483
|
+
const parent = await harness.conversation(id, BACKGROUND_CONTEXT);
|
|
484
|
+
if (parent === undefined) {
|
|
485
|
+
return notEligible("the conversation does not exist");
|
|
486
|
+
}
|
|
487
|
+
if (await isInternal(harness, id, BACKGROUND_CONTEXT)) {
|
|
488
|
+
return notEligible("the conversation is internal");
|
|
489
|
+
}
|
|
490
|
+
const meta = await harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
|
|
491
|
+
const owner = meta?.owner;
|
|
492
|
+
if (owner === undefined) {
|
|
493
|
+
return notEligible("the conversation has no owner");
|
|
494
|
+
}
|
|
495
|
+
const newest = await newestEntry(parent, BACKGROUND_CONTEXT);
|
|
496
|
+
if (newest === undefined) {
|
|
497
|
+
return notEligible("the conversation has no history to review");
|
|
498
|
+
}
|
|
499
|
+
// The model must be able to emit tool calls: a resolved agent with a
|
|
500
|
+
// model and a non-empty offered tool set. (pi-ai exposes no
|
|
501
|
+
// per-model "supports tools" flag; an empty offered set is the
|
|
502
|
+
// honest proxy — the model has nothing to call.)
|
|
503
|
+
const agent = await parent.agent(BACKGROUND_CONTEXT);
|
|
504
|
+
if (agent.model === undefined) {
|
|
505
|
+
return notEligible("the conversation has no model");
|
|
506
|
+
}
|
|
507
|
+
if (agent.tools.length === 0) {
|
|
508
|
+
return notEligible("the conversation's model has no tools to call");
|
|
509
|
+
}
|
|
510
|
+
// SAFETY: AgentDoc's stored state is the JsonObject mirror of
|
|
511
|
+
// pi-durable's AgentState (typed names/arrays); the record is only
|
|
512
|
+
// ever read field-by-field below through the same casts
|
|
513
|
+
// rebuildAgentWithGate applies, so the widening is read-only.
|
|
514
|
+
const stored = (await harness.snapshot(AgentDoc, id, BACKGROUND_CONTEXT));
|
|
515
|
+
const fork = await parent.fork(newest, {
|
|
516
|
+
ownership: { kind: "ownerless" },
|
|
517
|
+
agent: rebuildAgentWithGate(stored),
|
|
518
|
+
init: async (tx, forkId) => {
|
|
519
|
+
await markInternal(tx, forkId, owner);
|
|
520
|
+
const doc = await tx.doc(CurationRunDoc, forkId);
|
|
521
|
+
doc.user = owner;
|
|
522
|
+
doc.reviewId = opts.reviewId;
|
|
523
|
+
doc.allow = [...opts.allow];
|
|
524
|
+
doc.parent = id;
|
|
525
|
+
// The fork's copied meta carries the parent's sharing; an
|
|
526
|
+
// internal run is nobody's shared copy (A2's invisibility
|
|
527
|
+
// makes the key inert, but the doc should not carry a
|
|
528
|
+
// stale sharing set either). SAFETY: the tracked-draft
|
|
529
|
+
// write pattern mutates through a structural view; deleting
|
|
530
|
+
// the optional key is the same patch forkOp's own reset
|
|
531
|
+
// applies.
|
|
532
|
+
const forkMeta = await tx.doc(SessionMetaDoc, forkId);
|
|
533
|
+
if (forkMeta.sharing !== undefined) {
|
|
534
|
+
// SAFETY: cast — the structural view above (comment says why).
|
|
535
|
+
delete forkMeta.sharing;
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
}, BACKGROUND_CONTEXT);
|
|
539
|
+
return await driveRun(deps, async () => fork, opts, id);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* The digest-run producer: a fresh internal conversation owned by
|
|
543
|
+
* `user`, model = `opts.model` ?? the server default, selection =
|
|
544
|
+
* default + gate, same run doc and driver.
|
|
545
|
+
*/
|
|
546
|
+
export async function digestRunRun(deps, user, opts, defaultModel, defaultAgent) {
|
|
547
|
+
checkRunOpts(opts);
|
|
548
|
+
const { harness } = deps;
|
|
549
|
+
const conversation = await harness.createConversation({
|
|
550
|
+
ownership: { kind: "ownerless" },
|
|
551
|
+
agent: {
|
|
552
|
+
...defaultAgent(),
|
|
553
|
+
model: opts.model === undefined
|
|
554
|
+
? defaultModel
|
|
555
|
+
: parseModel(opts.model, defaultModel),
|
|
556
|
+
// The gate rides the DEFAULT selection (an {add} edit, never
|
|
557
|
+
// a pin — a digest has no parent selection to inherit).
|
|
558
|
+
extensions: { add: [named(CURATION_GATE_EXTENSION)] },
|
|
559
|
+
},
|
|
560
|
+
init: async (tx, id) => {
|
|
561
|
+
await markInternal(tx, id, user);
|
|
562
|
+
const doc = await tx.doc(CurationRunDoc, id);
|
|
563
|
+
doc.user = user;
|
|
564
|
+
doc.reviewId = opts.reviewId;
|
|
565
|
+
doc.allow = [...opts.allow];
|
|
566
|
+
},
|
|
567
|
+
}, BACKGROUND_CONTEXT);
|
|
568
|
+
return await driveRun(deps, async () => conversation, { ...opts, instruction: opts.input });
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Parse `provider/model` (the wire grammar), falling back to the
|
|
572
|
+
* default when the string has no provider part.
|
|
573
|
+
*/
|
|
574
|
+
function parseModel(model, defaultModel) {
|
|
575
|
+
const slash = model.indexOf("/");
|
|
576
|
+
if (slash <= 0)
|
|
577
|
+
return defaultModel;
|
|
578
|
+
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
|
579
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Deliberately conservative: learned text is instructions, not a secret store. */
|
|
2
|
+
const RULES = {
|
|
3
|
+
credential: [
|
|
4
|
+
/\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[a-z0-9_-]{12,}/i,
|
|
5
|
+
/\bsk-[a-z0-9_-]{12,}/i,
|
|
6
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
7
|
+
],
|
|
8
|
+
injection: [
|
|
9
|
+
/\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|above)\s+instructions\b/i,
|
|
10
|
+
/\bdisregard\s+(?:all\s+)?(?:previous|prior)\s+instructions\b/i,
|
|
11
|
+
/\bsystem\s+prompt\s*:\s*you\s+(?:are|must)\b/i,
|
|
12
|
+
],
|
|
13
|
+
exfiltration: [
|
|
14
|
+
/\b(?:send|upload|post|transmit)\b[^\n]*(?:~\/\.ssh|\/etc\/passwd|credentials?|secrets?)[^\n]*https?:\/\//i,
|
|
15
|
+
/\b(?:curl|wget)\b[^\n]*(?:~\/\.ssh|\/etc\/passwd|credentials?|secrets?)/i,
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
const ORDER = ["credential", "injection", "exfiltration"];
|
|
19
|
+
/** Scan line-by-line so a refusal can name the exact line to revise. */
|
|
20
|
+
export function scanContent(text) {
|
|
21
|
+
const lines = text.split(/\r?\n/);
|
|
22
|
+
for (const rule of ORDER) {
|
|
23
|
+
for (let index = 0; index < lines.length; index++) {
|
|
24
|
+
if (RULES[rule].some((pattern) => pattern.test(lines[index]))) {
|
|
25
|
+
return { ok: false, line: index + 1, rule };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|