@plurnk/plurnk-service 0.3.0 → 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/README.md +13 -44
- package/SPEC.md +559 -447
- package/bin/plurnk-service.js +2 -1
- package/dist/core/ChannelWrite.d.ts +15 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/Engine.d.ts +15 -4
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +417 -114
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/PluginLoader.js +6 -6
- package/dist/core/PluginLoader.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts +4 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -0
- package/dist/core/ProviderInstantiate.js +41 -0
- package/dist/core/ProviderInstantiate.js.map +1 -0
- package/dist/core/ProviderRegistry.d.ts.map +1 -1
- package/dist/core/ProviderRegistry.js +1 -1
- package/dist/core/ProviderRegistry.js.map +1 -1
- package/dist/core/SchemeRegistry.d.ts +1 -1
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +4 -20
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/line-marker.d.ts +23 -0
- package/dist/core/line-marker.d.ts.map +1 -0
- package/dist/core/line-marker.js +321 -0
- package/dist/core/line-marker.js.map +1 -0
- package/dist/core/matcher.d.ts +12 -0
- package/dist/core/matcher.d.ts.map +1 -0
- package/dist/core/matcher.js +72 -0
- package/dist/core/matcher.js.map +1 -0
- package/dist/core/mimetype-binary.d.ts +6 -0
- package/dist/core/mimetype-binary.d.ts.map +1 -0
- package/dist/core/mimetype-binary.js +82 -0
- package/dist/core/mimetype-binary.js.map +1 -0
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +206 -30
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/path-mimetype.d.ts +3 -0
- package/dist/core/path-mimetype.d.ts.map +1 -0
- package/dist/core/path-mimetype.js +47 -0
- package/dist/core/path-mimetype.js.map +1 -0
- package/dist/core/scheme-types.d.ts +8 -26
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js +5 -8
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/schemes/Exec.d.ts +30 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +217 -6
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts +5 -0
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +113 -23
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +3 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +88 -13
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Plurnk.js +3 -3
- package/dist/schemes/Plurnk.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +2 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +1 -0
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +64 -15
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +3 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +262 -54
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +14 -7
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/server/ClientConnection.d.ts +3 -2
- package/dist/server/ClientConnection.d.ts.map +1 -1
- package/dist/server/ClientConnection.js +4 -1
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +51 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +337 -1
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +31 -2
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/MethodRegistry.js.map +1 -1
- package/dist/server/dsl.d.ts +2 -2
- package/dist/server/dsl.d.ts.map +1 -1
- package/dist/server/dsl.js +10 -10
- package/dist/server/dsl.js.map +1 -1
- package/dist/server/methods/entry_read.js +5 -5
- package/dist/server/methods/entry_read.js.map +1 -1
- package/dist/server/methods/loop_cancel.d.ts +3 -0
- package/dist/server/methods/loop_cancel.d.ts.map +1 -0
- package/dist/server/methods/loop_cancel.js +27 -0
- package/dist/server/methods/loop_cancel.js.map +1 -0
- package/dist/server/methods/loop_resolve.js +2 -2
- package/dist/server/methods/loop_resolve.js.map +1 -1
- package/dist/server/methods/loop_run.d.ts.map +1 -1
- package/dist/server/methods/loop_run.js +48 -48
- package/dist/server/methods/loop_run.js.map +1 -1
- package/dist/server/methods/op_edit.js +3 -3
- package/dist/server/methods/op_edit.js.map +1 -1
- package/dist/server/methods/op_hide.js +3 -3
- package/dist/server/methods/op_hide.js.map +1 -1
- package/dist/server/methods/op_read.js +3 -3
- package/dist/server/methods/op_read.js.map +1 -1
- package/dist/server/methods/op_show.js +3 -3
- package/dist/server/methods/op_show.js.map +1 -1
- package/dist/server/methods/providers_list.js +1 -1
- package/dist/server/methods/providers_list.js.map +1 -1
- package/migrations/001_schema.sql +1 -1
- package/package.json +19 -14
package/dist/core/Engine.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { PlurnkParser } from "@plurnk/plurnk-grammar";
|
|
1
|
+
import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
|
|
2
2
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
3
|
+
import { writeEntry } from "../schemes/_entry-crud.js";
|
|
3
4
|
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
5
|
+
import { sliceLinesRaw, isBinaryMimetype } from "@plurnk/plurnk-schemes";
|
|
4
6
|
// Plain JS module shared with bin/digest.js so wire projection and
|
|
5
7
|
// digest projection are structurally one function. tsconfig.build.json
|
|
6
8
|
// has allowJs:true so this gets copied through to dist/.
|
|
7
9
|
import { packetToWireMessages } from "./packet-wire.js";
|
|
8
|
-
//
|
|
10
|
+
// SPEC §3.6: writer must be in target scheme's manifest.writableBy.
|
|
9
11
|
// SHOW/HIDE/READ/FIND are not gated — they touch visibility metadata or read.
|
|
10
12
|
const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC"]);
|
|
11
13
|
const DEFAULT_PREVIEW_BUDGET = 256;
|
|
12
14
|
const DEFAULT_MAX_STRIKES = 3;
|
|
15
|
+
const DEFAULT_MAX_COMMANDS = 99;
|
|
13
16
|
const readBudget = () => {
|
|
14
17
|
const raw = process.env.PLURNK_ENTRY_SIZE_DEFAULT_TOKENS;
|
|
15
18
|
if (raw === undefined || raw.length === 0)
|
|
@@ -28,8 +31,17 @@ const readMaxStrikes = () => {
|
|
|
28
31
|
return DEFAULT_MAX_STRIKES;
|
|
29
32
|
return n;
|
|
30
33
|
};
|
|
34
|
+
const readMaxCommands = () => {
|
|
35
|
+
const raw = process.env.PLURNK_MAX_COMMANDS;
|
|
36
|
+
if (raw === undefined || raw.length === 0)
|
|
37
|
+
return DEFAULT_MAX_COMMANDS;
|
|
38
|
+
const n = Number.parseInt(raw, 10);
|
|
39
|
+
if (!Number.isFinite(n) || n < 1)
|
|
40
|
+
return DEFAULT_MAX_COMMANDS;
|
|
41
|
+
return n;
|
|
42
|
+
};
|
|
31
43
|
// Resolution timeout — proposed entries auto-cancel if nothing arrives
|
|
32
|
-
// within this window.
|
|
44
|
+
// within this window. SPEC.md §0.5 (proposal lifecycle) + §13.5 (loop.resolve).
|
|
33
45
|
const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
|
|
34
46
|
const readProposalTimeoutMs = () => {
|
|
35
47
|
const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
|
|
@@ -67,17 +79,57 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
67
79
|
return fallback;
|
|
68
80
|
return n;
|
|
69
81
|
};
|
|
70
|
-
// Per-op fingerprint: op verb + target URI
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
82
|
+
// Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
|
|
83
|
+
// where the activity isn't fully captured by target alone:
|
|
84
|
+
// - EDIT/COPY/MOVE: body excluded — re-writing the same target with varied
|
|
85
|
+
// content IS cycling (the model is producing different versions of the
|
|
86
|
+
// same artifact instead of progressing).
|
|
87
|
+
// - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
|
|
88
|
+
// matchers on the same target ARE different activities (the model is
|
|
89
|
+
// exploring different queries, not repeating one).
|
|
74
90
|
const fingerprintOp = (stmt) => {
|
|
75
|
-
const path = stmt.
|
|
76
|
-
|
|
91
|
+
const path = stmt.target;
|
|
92
|
+
const matcherDiscriminator = () => {
|
|
93
|
+
// For matcher-bearing ops, the body's `raw` (matcher source) plus
|
|
94
|
+
// any lineMarker forms the activity discriminator.
|
|
95
|
+
const parts = [];
|
|
96
|
+
const body = stmt.body;
|
|
97
|
+
if (body !== null && typeof body === "object" && typeof body.raw === "string") {
|
|
98
|
+
parts.push(`body:${body.raw.slice(0, 64)}`);
|
|
99
|
+
}
|
|
100
|
+
const lm = stmt.lineMarker;
|
|
101
|
+
if (lm !== null && lm !== undefined)
|
|
102
|
+
parts.push(`L:${lm.first},${lm.last ?? ""}`);
|
|
103
|
+
return parts.length > 0 ? `|${parts.join("|")}` : "";
|
|
104
|
+
};
|
|
105
|
+
if (path === null) {
|
|
106
|
+
// Path-less ops need an activity-defining discriminator other
|
|
107
|
+
// than `target`. Picked per op so the cycle detector reflects
|
|
108
|
+
// intent rather than syntax:
|
|
109
|
+
// - EXEC: the command body IS the activity. Without a body
|
|
110
|
+
// digest, varied shell commands (find / ls / wc) collapse to
|
|
111
|
+
// one fingerprint and the detector mislabels exploration
|
|
112
|
+
// as a loop.
|
|
113
|
+
// - SEND: the status code (signal) IS the activity. Different
|
|
114
|
+
// SEND[X] are different intentions; same SEND[X] with
|
|
115
|
+
// different message bodies is the same termination signal.
|
|
116
|
+
if (stmt.op === "EXEC") {
|
|
117
|
+
const body = typeof stmt.body === "string" ? stmt.body : "";
|
|
118
|
+
return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
|
|
119
|
+
}
|
|
120
|
+
if (stmt.op === "SEND") {
|
|
121
|
+
const signal = typeof stmt.signal === "number" ? stmt.signal : "";
|
|
122
|
+
return `SEND|(no-path)|signal:${signal}`;
|
|
123
|
+
}
|
|
77
124
|
return `${stmt.op}|(no-path)`;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
125
|
+
}
|
|
126
|
+
const base = path.kind === "url"
|
|
127
|
+
? `${stmt.op}|${path.scheme}://${path.pathname}`
|
|
128
|
+
: `${stmt.op}|local:${path.raw}`;
|
|
129
|
+
if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
|
|
130
|
+
return `${base}${matcherDiscriminator()}`;
|
|
131
|
+
}
|
|
132
|
+
return base;
|
|
81
133
|
};
|
|
82
134
|
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
83
135
|
// within a turn doesn't matter — we want the SET of activities.
|
|
@@ -123,11 +175,10 @@ export default class Engine {
|
|
|
123
175
|
// rails (cycle detection #39, etc.) — read and reset at end of each turn.
|
|
124
176
|
// `history` holds per-turn fingerprints for rail #39 cycle detection.
|
|
125
177
|
#strikeState = new Map();
|
|
126
|
-
// Proposal lifecycle
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// §Phase E for the broader lifecycle plan.
|
|
178
|
+
// Proposal lifecycle: pending dispatch pauses waiting for resolution.
|
|
179
|
+
// Engine.runTurn awaits the promise when a scheme returns status 202;
|
|
180
|
+
// Engine.resolveProposal feeds the resolution back in. Map is per-log-
|
|
181
|
+
// entry-id; entries clear on resolution. SPEC.md §0.5 + §13.5 (loop.resolve).
|
|
131
182
|
#pendingProposals = new Map();
|
|
132
183
|
// External observers of proposal lifecycle events. Daemon subscribes
|
|
133
184
|
// here to push `loop/proposal` notifications when an entry enters
|
|
@@ -135,9 +186,26 @@ export default class Engine {
|
|
|
135
186
|
// event emitter — no priority, no veto chain at this layer; filter
|
|
136
187
|
// chains come later if a real consumer needs them.
|
|
137
188
|
#proposalPendingListeners = [];
|
|
138
|
-
|
|
189
|
+
// Per-loop AbortController for cancellation propagation into scheme
|
|
190
|
+
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
191
|
+
// cancellation paths (strikes, max_turns, external) abort it.
|
|
192
|
+
// Streaming schemes (exec) chain their per-spawn controllers off
|
|
193
|
+
// ctx.signal so cancelled loops tear down their background spawns.
|
|
194
|
+
#loopAborts = new Map();
|
|
195
|
+
#streamEventNotify;
|
|
196
|
+
#wakeRunNotify;
|
|
197
|
+
// Telemetry event fan-out: every TelemetryEvent pushed to the loop's
|
|
198
|
+
// buffer is also broadcast live to the connected client(s) on the
|
|
199
|
+
// session. Without this, the client sees `loop/terminated` with a
|
|
200
|
+
// status code but has no way to surface why the loop degraded.
|
|
201
|
+
// Per-grammar 0.17.0 protocol — see SPEC §15.1.
|
|
202
|
+
#telemetryEventNotify;
|
|
203
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, telemetryEventNotify }) {
|
|
139
204
|
this.#db = db;
|
|
140
205
|
this.#schemes = schemes;
|
|
206
|
+
this.#streamEventNotify = streamEventNotify;
|
|
207
|
+
this.#wakeRunNotify = wakeRunNotify;
|
|
208
|
+
this.#telemetryEventNotify = telemetryEventNotify;
|
|
141
209
|
// Default to empty discovery — standalone Engine construction (in
|
|
142
210
|
// tests) gets no handlers, and content flows through the framework's
|
|
143
211
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
@@ -147,12 +215,16 @@ export default class Engine {
|
|
|
147
215
|
});
|
|
148
216
|
this.#previewBudget = readBudget();
|
|
149
217
|
}
|
|
150
|
-
#pushTelemetry(loopId,
|
|
218
|
+
#pushTelemetry(sessionId, loopId, event) {
|
|
151
219
|
const existing = this.#telemetryBuffer.get(loopId);
|
|
152
220
|
if (existing === undefined)
|
|
153
|
-
this.#telemetryBuffer.set(loopId, [
|
|
221
|
+
this.#telemetryBuffer.set(loopId, [event]);
|
|
154
222
|
else
|
|
155
|
-
existing.push(
|
|
223
|
+
existing.push(event);
|
|
224
|
+
// Live fan-out: client sees the event the moment it lands in the
|
|
225
|
+
// model's buffer (not at the next packet build). Same envelope on
|
|
226
|
+
// both sides per the grammar 0.17.0 TelemetryEvent protocol.
|
|
227
|
+
this.#telemetryEventNotify?.(sessionId, { loopId, event });
|
|
156
228
|
}
|
|
157
229
|
#drainTelemetry(loopId) {
|
|
158
230
|
const buf = this.#telemetryBuffer.get(loopId);
|
|
@@ -161,10 +233,53 @@ export default class Engine {
|
|
|
161
233
|
this.#telemetryBuffer.delete(loopId);
|
|
162
234
|
return buf;
|
|
163
235
|
}
|
|
164
|
-
|
|
236
|
+
// Pull a ±windowLines context block around `targetLine` (1-based) from
|
|
237
|
+
// `content`, formatted with N:\t prefixes — same shape the model
|
|
238
|
+
// already knows from READ output and numbered Index entries. Used to
|
|
239
|
+
// give parse_error telemetry concrete locality: the model sees what
|
|
240
|
+
// it wrote on the offending line, not just an abstract error message.
|
|
241
|
+
//
|
|
242
|
+
// Lenient: targetLine ≤ 0 clamps to 1; targetLine beyond content
|
|
243
|
+
// returns whatever overlap exists; empty content returns "".
|
|
244
|
+
#extractSnippet(content, targetLine, windowLines) {
|
|
245
|
+
if (content.length === 0)
|
|
246
|
+
return "";
|
|
247
|
+
const lines = content.split("\n");
|
|
248
|
+
const target = Math.max(1, targetLine);
|
|
249
|
+
const start = Math.max(1, target - windowLines);
|
|
250
|
+
const end = Math.min(lines.length, target + windowLines);
|
|
251
|
+
const slice = [];
|
|
252
|
+
for (let i = start; i <= end; i++) {
|
|
253
|
+
slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
|
|
254
|
+
}
|
|
255
|
+
return slice.join("\n");
|
|
256
|
+
}
|
|
257
|
+
async runLoop({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
|
|
165
258
|
const turnIds = [];
|
|
166
259
|
const suddenDeathThreshold = maxTurns - maxStrikes;
|
|
167
|
-
|
|
260
|
+
// Per-loop AbortController for scheme-side cancellation propagation.
|
|
261
|
+
// Chained from the caller's `signal` so an external abort cascades.
|
|
262
|
+
const loopAbort = new AbortController();
|
|
263
|
+
if (signal !== undefined) {
|
|
264
|
+
if (signal.aborted)
|
|
265
|
+
loopAbort.abort(signal.reason);
|
|
266
|
+
else
|
|
267
|
+
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
268
|
+
}
|
|
269
|
+
this.#loopAborts.set(loopId, loopAbort);
|
|
270
|
+
// Cleanup splits by termination kind:
|
|
271
|
+
// - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
|
|
272
|
+
// spawns are ALLOWED to outlive the loop. They complete naturally,
|
|
273
|
+
// write final channel state, and wake-on-completion (E.4) opens a
|
|
274
|
+
// fresh loop in the same run if the model needs to react.
|
|
275
|
+
// - "forceful" (max_turns, strike_threshold, external cancel,
|
|
276
|
+
// non-2xx loop status): fire the loop-level abort so spawns
|
|
277
|
+
// tear down immediately.
|
|
278
|
+
const cleanup = (kind, reason) => {
|
|
279
|
+
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
280
|
+
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
281
|
+
}
|
|
282
|
+
this.#loopAborts.delete(loopId);
|
|
168
283
|
this.#strikeState.delete(loopId);
|
|
169
284
|
this.#telemetryBuffer.delete(loopId);
|
|
170
285
|
};
|
|
@@ -174,16 +289,19 @@ export default class Engine {
|
|
|
174
289
|
if (row === undefined)
|
|
175
290
|
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
176
291
|
if (row.status !== 102) {
|
|
177
|
-
|
|
292
|
+
// Status 2xx = graceful (model said done); 4xx/5xx = forceful
|
|
293
|
+
// (external cancel or upstream failure). The threshold splits
|
|
294
|
+
// at 400 to match HTTP success/error semantics.
|
|
295
|
+
cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
|
|
178
296
|
return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
|
|
179
297
|
}
|
|
180
298
|
if (turnIds.length >= maxTurns) {
|
|
181
299
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
182
|
-
cleanup();
|
|
300
|
+
cleanup("forceful", "max_turns");
|
|
183
301
|
return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
|
|
184
302
|
}
|
|
185
303
|
const turn = await this.runTurn({
|
|
186
|
-
provider, messages, persona, sessionId, runId, loopId, origin, signal, onDispatch,
|
|
304
|
+
provider, messages, persona, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
|
|
187
305
|
turnNumber: turnIds.length + 1, maxTurns,
|
|
188
306
|
});
|
|
189
307
|
turnIds.push(turn.turnId);
|
|
@@ -195,11 +313,10 @@ export default class Engine {
|
|
|
195
313
|
const cycle = detectCycle(state.history, minCycles, maxCyclePeriod);
|
|
196
314
|
if (cycle.detected) {
|
|
197
315
|
state.turnErrors++;
|
|
198
|
-
this.#pushTelemetry(loopId, {
|
|
316
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
199
317
|
kind: "cycle",
|
|
200
318
|
period: cycle.period,
|
|
201
319
|
cycles: cycle.cycles,
|
|
202
|
-
message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
|
|
203
320
|
});
|
|
204
321
|
}
|
|
205
322
|
this.#strikeState.set(loopId, state);
|
|
@@ -214,15 +331,16 @@ export default class Engine {
|
|
|
214
331
|
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
215
332
|
if (struck) {
|
|
216
333
|
state.streak++;
|
|
217
|
-
|
|
334
|
+
const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
|
|
335
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
218
336
|
kind: "strike",
|
|
219
337
|
streak: state.streak,
|
|
220
338
|
maxStrikes,
|
|
221
|
-
reason
|
|
339
|
+
reason,
|
|
222
340
|
});
|
|
223
341
|
if (state.streak >= maxStrikes) {
|
|
224
342
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
225
|
-
cleanup();
|
|
343
|
+
cleanup("forceful", "strike_threshold");
|
|
226
344
|
return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
|
|
227
345
|
}
|
|
228
346
|
}
|
|
@@ -236,15 +354,14 @@ export default class Engine {
|
|
|
236
354
|
// each turn so the model can wrap up before the hard cancel.
|
|
237
355
|
// Soft: no strike, no loop-status change. SPEC §15.1.
|
|
238
356
|
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
239
|
-
this.#pushTelemetry(loopId, {
|
|
357
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
240
358
|
kind: "sudden_death",
|
|
241
|
-
message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
|
|
242
359
|
remaining: maxTurns - turnIds.length,
|
|
243
360
|
});
|
|
244
361
|
}
|
|
245
362
|
}
|
|
246
363
|
}
|
|
247
|
-
async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
364
|
+
async runTurn({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
248
365
|
// === Turn-as-container model ===
|
|
249
366
|
//
|
|
250
367
|
// Turn rows are created at runTurn OPEN (status=102, placeholder
|
|
@@ -263,26 +380,30 @@ export default class Engine {
|
|
|
263
380
|
if (openRow === undefined)
|
|
264
381
|
throw new Error("Engine.runTurn: turn open returned no row");
|
|
265
382
|
const turnId = openRow.id;
|
|
266
|
-
// Pre-model writes.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// 1
|
|
383
|
+
// Pre-model writes. Each turn opens with a system-origin EDIT
|
|
384
|
+
// against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
|
|
385
|
+
// for THIS turn the model hasn't seen yet:
|
|
386
|
+
// - Turn 1: loop.prompt is the initial user prompt.
|
|
387
|
+
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
388
|
+
// daemon.inject) wrote a prompt entry for this turn slot
|
|
389
|
+
// between turn N-1 and N. Inject writes directly to entries;
|
|
390
|
+
// we DON'T re-foist here for N>1.
|
|
391
|
+
// The log records the EDIT for forensics. Model ops dispatch
|
|
392
|
+
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
393
|
+
// otherwise.
|
|
273
394
|
let nextActionIndex = 1;
|
|
274
395
|
if (seq === 1) {
|
|
275
396
|
const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
|
|
276
397
|
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
277
398
|
const promptPath = {
|
|
278
|
-
kind: "url", raw: `plurnk://prompt/${loopId}`,
|
|
399
|
+
kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
|
|
279
400
|
scheme: "plurnk", username: null, password: null,
|
|
280
401
|
hostname: null, port: null,
|
|
281
|
-
pathname: `prompt/${loopId}`, params: {}, fragment: null,
|
|
402
|
+
pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
|
|
282
403
|
};
|
|
283
404
|
const promptStmt = {
|
|
284
405
|
op: "EDIT", suffix: "", signal: null,
|
|
285
|
-
|
|
406
|
+
target: promptPath, lineMarker: null,
|
|
286
407
|
body: promptRow.prompt, position: { line: 1, column: 1 },
|
|
287
408
|
};
|
|
288
409
|
await this.dispatch({
|
|
@@ -296,8 +417,8 @@ export default class Engine {
|
|
|
296
417
|
// queries log_entries scoped to the run — the prompt entry just
|
|
297
418
|
// written (if turn 1) is part of that query result.
|
|
298
419
|
const requestPacket = await this.#buildRequestPacket({
|
|
299
|
-
initialMessages: messages, persona, runId, loopId,
|
|
300
|
-
|
|
420
|
+
initialMessages: messages, persona, requirements, runId, loopId,
|
|
421
|
+
currentTurnSeq: seq, provider,
|
|
301
422
|
});
|
|
302
423
|
const modelMessages = this.#packetToWireMessages(requestPacket);
|
|
303
424
|
const response = await provider.generate({ messages: modelMessages, signal });
|
|
@@ -305,7 +426,30 @@ export default class Engine {
|
|
|
305
426
|
// parsed ops) → packet.assistant per Packet.json §assistant;
|
|
306
427
|
// call-metadata (usage, finishReason, model) → Turn columns per
|
|
307
428
|
// Turn.json. Mixing the two on packet.assistant was the wrong layer.
|
|
308
|
-
const { packetAssistant, callMetadata } = this.#splitResponse(response);
|
|
429
|
+
const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
|
|
430
|
+
// Surface parse errors to the model's NEXT packet so it can self-
|
|
431
|
+
// correct. Without this, malformed emissions (e.g. a READ matcher
|
|
432
|
+
// body starting with `//` being interpreted as xpath) silently
|
|
433
|
+
// drop, the model sees zero ops dispatched, strike-rail fires,
|
|
434
|
+
// model has no feedback on WHY its emission didn't take effect.
|
|
435
|
+
//
|
|
436
|
+
// Envelope per @plurnk/plurnk-grammar 0.17.0 TelemetryEvent:
|
|
437
|
+
// { source, kind, message, position: { type: "content-offset", line, column } }
|
|
438
|
+
// Plus a `snippet` field (additionalProperties) carrying ±N lines
|
|
439
|
+
// of the assistant's own content around the error line. Without
|
|
440
|
+
// the snippet, the model sees "invalid xpath at 1:0" but can't
|
|
441
|
+
// connect that to what IT wrote — and tends to regenerate the
|
|
442
|
+
// same broken emission. See edit-todo demo for the canonical case.
|
|
443
|
+
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
444
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
445
|
+
source: "grammar",
|
|
446
|
+
kind: "parse_error",
|
|
447
|
+
message,
|
|
448
|
+
position: { type: "content-offset", line, column },
|
|
449
|
+
snippet: this.#extractSnippet(packetAssistant.content, line, 2),
|
|
450
|
+
parserSource: source,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
309
453
|
const opsCount = packetAssistant.ops.length;
|
|
310
454
|
const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
|
|
311
455
|
// Rail #41 (revised): the per-turn requirement is "emit at least one
|
|
@@ -330,8 +474,19 @@ export default class Engine {
|
|
|
330
474
|
});
|
|
331
475
|
// Dispatch model ops starting at nextActionIndex (continues the
|
|
332
476
|
// turn's running counter after any pre-model writes).
|
|
477
|
+
//
|
|
478
|
+
// Max-commands cap: a single emission with more than `maxCommands`
|
|
479
|
+
// ops is the runaway-loop fingerprint observed in pathological cases
|
|
480
|
+
// (html-attrs demo: 635 ops in one turn). Cap dispatches at the
|
|
481
|
+
// configured limit; overflow ops are dropped without per-op log
|
|
482
|
+
// entries (avoids bloating forensics with hundreds of identical refusals)
|
|
483
|
+
// and the model gets a single telemetry signal next packet so it knows
|
|
484
|
+
// its emission was truncated.
|
|
485
|
+
const maxCommands = readMaxCommands();
|
|
486
|
+
const opsToDispatch = packetAssistant.ops.slice(0, maxCommands);
|
|
487
|
+
const droppedCount = opsCount - opsToDispatch.length;
|
|
333
488
|
const statuses = [];
|
|
334
|
-
for (const [i, statement] of
|
|
489
|
+
for (const [i, statement] of opsToDispatch.entries()) {
|
|
335
490
|
const result = await this.dispatch({
|
|
336
491
|
statement, sessionId, runId, loopId, turnId,
|
|
337
492
|
sequence: nextActionIndex + i,
|
|
@@ -339,14 +494,21 @@ export default class Engine {
|
|
|
339
494
|
});
|
|
340
495
|
statuses.push(result.status);
|
|
341
496
|
}
|
|
497
|
+
if (droppedCount > 0) {
|
|
498
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
499
|
+
kind: "max_commands_exceeded",
|
|
500
|
+
emitted: opsCount,
|
|
501
|
+
cap: maxCommands,
|
|
502
|
+
dropped: droppedCount,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
342
505
|
if (opsCount === 0) {
|
|
343
506
|
// Rail #41 (revised): per-turn requirement is "emit at least one
|
|
344
507
|
// op." Zero ops = actionless failure. SEND specifically is not
|
|
345
508
|
// required — any of the 9 grammar ops satisfies. Pushed AFTER
|
|
346
509
|
// #buildPacket so this turn's drain doesn't consume it.
|
|
347
|
-
this.#pushTelemetry(loopId, {
|
|
510
|
+
this.#pushTelemetry(sessionId, loopId, {
|
|
348
511
|
kind: "no_ops",
|
|
349
|
-
message: "turn ended without emitting any op; emit at least one operation per turn",
|
|
350
512
|
});
|
|
351
513
|
}
|
|
352
514
|
return { turnId, status: turnStatus, statuses, fingerprint: fingerprintTurn(packetAssistant.ops) };
|
|
@@ -354,8 +516,9 @@ export default class Engine {
|
|
|
354
516
|
// Split the wire-level ProviderResponse into the two destinations:
|
|
355
517
|
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
356
518
|
// Turn columns get the call-metadata (usage, finishReason, model).
|
|
357
|
-
//
|
|
358
|
-
// owns the parse and the scraping rule, providers stay
|
|
519
|
+
// SPEC §2.1 / plurnk-providers#1: text-fragment scraping policy lives
|
|
520
|
+
// here — engine owns the parse and the scraping rule, providers stay
|
|
521
|
+
// grammar-unaware.
|
|
359
522
|
//
|
|
360
523
|
// Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
|
|
361
524
|
// its assistant payload to skip the parse roundtrip. The wire Provider
|
|
@@ -366,6 +529,11 @@ export default class Engine {
|
|
|
366
529
|
const preParsedOps = assistant.ops;
|
|
367
530
|
const ops = [];
|
|
368
531
|
const textFragments = [];
|
|
532
|
+
// Full PlurnkParseError context (line/column/source) is preserved
|
|
533
|
+
// here so runTurn can build TelemetryEvent envelopes per the
|
|
534
|
+
// grammar 0.17.0 protocol — model needs position info to locate
|
|
535
|
+
// its own offending content on the next turn.
|
|
536
|
+
const parseErrors = [];
|
|
369
537
|
if (preParsedOps !== undefined) {
|
|
370
538
|
ops.push(...preParsedOps);
|
|
371
539
|
}
|
|
@@ -379,6 +547,16 @@ export default class Engine {
|
|
|
379
547
|
if (trimmed.length > 0)
|
|
380
548
|
textFragments.push(trimmed);
|
|
381
549
|
}
|
|
550
|
+
else if (item.kind === "error") {
|
|
551
|
+
const err = item.error;
|
|
552
|
+
if (err instanceof PlurnkParseError) {
|
|
553
|
+
parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const msg = err?.message ?? "parse error";
|
|
557
|
+
parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
382
560
|
}
|
|
383
561
|
}
|
|
384
562
|
const wireReasoning = assistant.reasoning ?? "";
|
|
@@ -388,19 +566,25 @@ export default class Engine {
|
|
|
388
566
|
return {
|
|
389
567
|
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
390
568
|
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
569
|
+
parseErrors,
|
|
391
570
|
};
|
|
392
571
|
}
|
|
393
572
|
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
394
573
|
// and §user) BEFORE the provider call. The same packet object is then
|
|
395
574
|
// completed with assistant + assistantRaw after the model responds, so
|
|
396
575
|
// the stored packet and the wire payload share one source of truth.
|
|
397
|
-
|
|
398
|
-
// a continuation turn"; the turn-N-of-M continuation marker rides on
|
|
399
|
-
// user.system_requirements (per-turn rules), NOT a mutated prompt.
|
|
400
|
-
async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
|
|
576
|
+
async #buildRequestPacket({ initialMessages, persona: defaultPersona, requirements, runId, loopId, currentTurnSeq, provider, }) {
|
|
401
577
|
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
402
578
|
const system_definition = byRole("system");
|
|
403
|
-
|
|
579
|
+
// user.prompt sources from the loop's most recent prompt entry first
|
|
580
|
+
// (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
|
|
581
|
+
// This is what inject + the turn-1 foist write into. Falls back to
|
|
582
|
+
// the runLoop caller's messages.user for tests that bypass the
|
|
583
|
+
// foist mechanism entirely.
|
|
584
|
+
const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
|
|
585
|
+
const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
|
|
586
|
+
? latestPromptRow.content
|
|
587
|
+
: byRole("user");
|
|
404
588
|
// Resolve persona cascade: loops.persona > runs.persona >
|
|
405
589
|
// sessions.persona > caller-supplied default. SQL coalesces in one
|
|
406
590
|
// query; null result means no DB override exists, use the default.
|
|
@@ -409,13 +593,6 @@ export default class Engine {
|
|
|
409
593
|
const index = await this.#buildIndex(runId, loopId);
|
|
410
594
|
const log = await this.#buildLog(runId);
|
|
411
595
|
const telemetryErrors = await this.#buildTelemetryErrors(loopId, currentTurnSeq);
|
|
412
|
-
// Rummy AgentLoop.js #buildContinuationPrompt: literally
|
|
413
|
-
// `Turn ${turn}/${maxTurns}`. That's the whole string. The model
|
|
414
|
-
// can read the action log to see what it already did; it does
|
|
415
|
-
// not need editorial instructions from us about not repeating.
|
|
416
|
-
const system_requirements = turnNumber > 1
|
|
417
|
-
? `Turn ${turnNumber}/${maxTurns}`
|
|
418
|
-
: "";
|
|
419
596
|
// Per-section render-cost subtotals via provider's tokenizer.
|
|
420
597
|
// Engine approximates each section by tokenizing its serialized
|
|
421
598
|
// form — wire-payload tokens may differ slightly because chat-
|
|
@@ -426,13 +603,13 @@ export default class Engine {
|
|
|
426
603
|
provider.countTokens(JSON.stringify(index)) +
|
|
427
604
|
provider.countTokens(JSON.stringify(log));
|
|
428
605
|
// user.telemetry.budget — shimmed with section-aggregate table.
|
|
429
|
-
// Real per-scheme breakdown is
|
|
430
|
-
//
|
|
431
|
-
const budget = this.#renderBudgetShim(systemTokens, provider, prompt,
|
|
606
|
+
// Real per-scheme breakdown is the tokenomics chapter (SPEC.md §14.2);
|
|
607
|
+
// depends on provider.getContextSize and write-time per-row tokens.
|
|
608
|
+
const budget = this.#renderBudgetShim(systemTokens, provider, prompt, telemetryErrors);
|
|
432
609
|
const userTokens = provider.countTokens(prompt) +
|
|
433
|
-
provider.countTokens(system_requirements) +
|
|
434
610
|
provider.countTokens(JSON.stringify(telemetryErrors)) +
|
|
435
|
-
provider.countTokens(budget)
|
|
611
|
+
provider.countTokens(budget) +
|
|
612
|
+
provider.countTokens(requirements);
|
|
436
613
|
return {
|
|
437
614
|
system: {
|
|
438
615
|
tokens: systemTokens,
|
|
@@ -445,20 +622,19 @@ export default class Engine {
|
|
|
445
622
|
tokens: userTokens,
|
|
446
623
|
prompt,
|
|
447
624
|
telemetry: { budget, errors: telemetryErrors },
|
|
448
|
-
system_requirements,
|
|
625
|
+
system_requirements: requirements,
|
|
449
626
|
},
|
|
450
627
|
};
|
|
451
628
|
}
|
|
452
|
-
// user.telemetry.budget — SHIM.
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
// aggregate counts we already compute so the wire's
|
|
456
|
-
// Budget` section is non-empty for picking-apart purposes.
|
|
457
|
-
#renderBudgetShim(systemTokens, provider, prompt,
|
|
629
|
+
// user.telemetry.budget — SHIM. Real per-scheme breakdown +
|
|
630
|
+
// context-window "free/percent-of-total" is SPEC.md §14.2; depends on
|
|
631
|
+
// provider.getContextSize and write-time per-row tokens. Until then,
|
|
632
|
+
// render the section-aggregate counts we already compute so the wire's
|
|
633
|
+
// `# Plurnk System Budget` section is non-empty for picking-apart purposes.
|
|
634
|
+
#renderBudgetShim(systemTokens, provider, prompt, telemetryErrors) {
|
|
458
635
|
const userPromptTokens = provider.countTokens(prompt);
|
|
459
|
-
const userReqTokens = provider.countTokens(system_requirements);
|
|
460
636
|
const userErrTokens = provider.countTokens(JSON.stringify(telemetryErrors));
|
|
461
|
-
const userTokens = userPromptTokens +
|
|
637
|
+
const userTokens = userPromptTokens + userErrTokens;
|
|
462
638
|
const total = systemTokens + userTokens;
|
|
463
639
|
const pct = (n) => total === 0 ? "0.0%" : `${((n / total) * 100).toFixed(1)}%`;
|
|
464
640
|
return [
|
|
@@ -511,7 +687,7 @@ export default class Engine {
|
|
|
511
687
|
op: r.op,
|
|
512
688
|
target,
|
|
513
689
|
status: r.status_rx,
|
|
514
|
-
|
|
690
|
+
error: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
|
|
515
691
|
? parsedRx.error
|
|
516
692
|
: typeof parsedRx === "string" ? parsedRx : "",
|
|
517
693
|
};
|
|
@@ -549,19 +725,26 @@ export default class Engine {
|
|
|
549
725
|
status: r.status_rx,
|
|
550
726
|
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
551
727
|
mimetype_rx: r.mimetype_rx,
|
|
728
|
+
tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
|
|
729
|
+
mimetype_tx: r.mimetype_tx,
|
|
552
730
|
}));
|
|
553
731
|
}
|
|
554
732
|
async #buildIndex(runId, currentLoopId) {
|
|
555
733
|
const rows = await this.#db.engine_render_index.all({ run_id: runId });
|
|
556
734
|
const tagsStmt = this.#db.engine_entry_tags;
|
|
557
|
-
// Foist the CURRENT loop's prompt
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
735
|
+
// Foist the CURRENT loop's prompt entries out of the index render —
|
|
736
|
+
// their bodies are materialized in packet.user.prompt instead. With
|
|
737
|
+
// multi-turn injection, a loop can have multiple prompt entries at
|
|
738
|
+
// plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
|
|
739
|
+
// only previous loops' prompts (still addressable for HIDE).
|
|
740
|
+
const foistedPrefix = `prompt/${currentLoopId}/`;
|
|
741
|
+
// Backward compat: legacy single-slot path. Tests / older runs may
|
|
742
|
+
// still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
|
|
743
|
+
const foistedExact = `prompt/${currentLoopId}`;
|
|
562
744
|
const entries = new Map();
|
|
563
745
|
for (const row of rows) {
|
|
564
|
-
if (row.scheme === "plurnk"
|
|
746
|
+
if (row.scheme === "plurnk"
|
|
747
|
+
&& (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
|
|
565
748
|
continue;
|
|
566
749
|
let entry = entries.get(row.entry_id);
|
|
567
750
|
if (entry === undefined) {
|
|
@@ -611,7 +794,10 @@ export default class Engine {
|
|
|
611
794
|
db: this.#db,
|
|
612
795
|
sessionId, runId, loopId, turnId,
|
|
613
796
|
writer: origin,
|
|
614
|
-
signal:
|
|
797
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
798
|
+
streamEventNotify: this.#streamEventNotify,
|
|
799
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
800
|
+
mimetypes: this.#mimetypes,
|
|
615
801
|
};
|
|
616
802
|
let result;
|
|
617
803
|
let denial = this.#checkWritable(statement, origin);
|
|
@@ -621,13 +807,13 @@ export default class Engine {
|
|
|
621
807
|
result = denial;
|
|
622
808
|
}
|
|
623
809
|
else {
|
|
624
|
-
//
|
|
810
|
+
// SPEC §3.6 + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
|
|
625
811
|
// exceptions become the action-entry's outcome (status 500), not a
|
|
626
812
|
// thrown bubble. The log_entry is the durable record; engine never
|
|
627
813
|
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
628
814
|
// those are system failures.
|
|
629
815
|
try {
|
|
630
|
-
if (statement.op === "SEND" && statement.
|
|
816
|
+
if (statement.op === "SEND" && statement.target === null) {
|
|
631
817
|
result = await this.#handleSendBroadcast(statement, loopId);
|
|
632
818
|
}
|
|
633
819
|
else if (statement.op === "COPY") {
|
|
@@ -636,8 +822,15 @@ export default class Engine {
|
|
|
636
822
|
else if (statement.op === "MOVE") {
|
|
637
823
|
result = await this.#handleMove(statement, schemeCtx);
|
|
638
824
|
}
|
|
825
|
+
else if (statement.op === "EXEC") {
|
|
826
|
+
// EXEC's target slot is `cwd`, not a scheme address.
|
|
827
|
+
// Per plurnk.md the op routes unconditionally to the
|
|
828
|
+
// exec scheme; the scheme handler reads runtime
|
|
829
|
+
// (signal), cwd (target), and command (body).
|
|
830
|
+
result = await this.#run("exec", statement, schemeCtx);
|
|
831
|
+
}
|
|
639
832
|
else {
|
|
640
|
-
result = await this.#run(this.#schemeNameOf(statement.
|
|
833
|
+
result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
641
834
|
}
|
|
642
835
|
}
|
|
643
836
|
catch (err) {
|
|
@@ -649,13 +842,13 @@ export default class Engine {
|
|
|
649
842
|
}
|
|
650
843
|
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
651
844
|
onDispatch?.(logEntryId);
|
|
652
|
-
// Proposal lifecycle (
|
|
653
|
-
// returns status 202, the entry is written as state='proposed';
|
|
845
|
+
// Proposal lifecycle (SPEC.md §0.5 + §13.5 loop.resolve). When a
|
|
846
|
+
// scheme returns status 202, the entry is written as state='proposed';
|
|
654
847
|
// dispatch then PAUSES on a per-entry waiter until resolution
|
|
655
848
|
// arrives via Engine.resolveProposal (from the loop/resolve RPC,
|
|
656
|
-
// YOLO listener, or timeout
|
|
657
|
-
//
|
|
658
|
-
//
|
|
849
|
+
// YOLO listener, or timeout). The post-resolution status replaces
|
|
850
|
+
// 202 in the result the caller sees, so runTurn never branches on
|
|
851
|
+
// a pending state.
|
|
659
852
|
if (result.status === 202) {
|
|
660
853
|
// Register the resolution waiter SYNCHRONOUSLY before any await
|
|
661
854
|
// yields. A same-tick resolveProposal() (e.g. from a test that
|
|
@@ -666,7 +859,7 @@ export default class Engine {
|
|
|
666
859
|
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
667
860
|
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
668
861
|
// resolve synchronously inside their handlers.
|
|
669
|
-
const target = this.#extractTarget(statement.
|
|
862
|
+
const target = this.#extractTarget(statement.target);
|
|
670
863
|
const flags = await this.#loadLoopFlags(loopId);
|
|
671
864
|
const event = {
|
|
672
865
|
logEntryId, sessionId, runId, loopId, turnId,
|
|
@@ -698,7 +891,10 @@ export default class Engine {
|
|
|
698
891
|
const { sessionId, runId, loopId, turnId } = ids;
|
|
699
892
|
if (resolution.decision !== "accept")
|
|
700
893
|
return resolution;
|
|
701
|
-
|
|
894
|
+
// EXEC routes to the exec scheme regardless of target (cwd, not
|
|
895
|
+
// a scheme address). All other ops resolve their handler from
|
|
896
|
+
// statement.target's scheme.
|
|
897
|
+
const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
|
|
702
898
|
if (schemeName === null)
|
|
703
899
|
return resolution;
|
|
704
900
|
const handler = this.#schemes.get(schemeName);
|
|
@@ -711,7 +907,9 @@ export default class Engine {
|
|
|
711
907
|
// operation's artifact visible in the next packet's index.
|
|
712
908
|
const applyCtx = {
|
|
713
909
|
db: this.#db, sessionId, runId, loopId, turnId,
|
|
714
|
-
writer: "model", signal:
|
|
910
|
+
writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
|
|
911
|
+
streamEventNotify: this.#streamEventNotify,
|
|
912
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
715
913
|
};
|
|
716
914
|
const applyResult = await handler.applyResolution({
|
|
717
915
|
attrs: (originalResult.attrs ?? {}),
|
|
@@ -724,6 +922,14 @@ export default class Engine {
|
|
|
724
922
|
body: applyResult.body,
|
|
725
923
|
};
|
|
726
924
|
}
|
|
925
|
+
// Propagate applyResolution.outcome onto the accepted resolution
|
|
926
|
+
// so the log entry's outcome column reflects operational metadata
|
|
927
|
+
// (e.g. exec's "exit_N"). Without this, only failures get an
|
|
928
|
+
// outcome on the durable record, and "ran cleanly but with a
|
|
929
|
+
// notable detail" has nowhere to land.
|
|
930
|
+
if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
|
|
931
|
+
return { ...resolution, outcome: applyResult.outcome };
|
|
932
|
+
}
|
|
727
933
|
return resolution;
|
|
728
934
|
}
|
|
729
935
|
catch (err) {
|
|
@@ -754,6 +960,52 @@ export default class Engine {
|
|
|
754
960
|
pendingProposalIds() {
|
|
755
961
|
return [...this.#pendingProposals.keys()];
|
|
756
962
|
}
|
|
963
|
+
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
964
|
+
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
965
|
+
// will pick up the channel transition at its next turn boundary. If no,
|
|
966
|
+
// the daemon opens a fresh loop with the wake prompt.
|
|
967
|
+
async hasActiveLoopForRun(runId) {
|
|
968
|
+
const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
|
|
969
|
+
return (row?.n ?? 0) > 0;
|
|
970
|
+
}
|
|
971
|
+
// Inject a prompt into the run's currently-executing loop. Writes a
|
|
972
|
+
// plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
|
|
973
|
+
// packet.user.prompt at the next turn boundary. Last-wins: if two
|
|
974
|
+
// injects target the same next-turn slot, the second overwrites the
|
|
975
|
+
// first.
|
|
976
|
+
//
|
|
977
|
+
// Returns null when no loop in the run is currently active (status=102).
|
|
978
|
+
// The daemon-side inject path then enqueues a fresh loop with this
|
|
979
|
+
// prompt; engine doesn't open loops itself.
|
|
980
|
+
//
|
|
981
|
+
// Rummy parallel: AgentLoop.inject(). The "active drain → write
|
|
982
|
+
// prompt entry, return immediately" branch.
|
|
983
|
+
async inject(runId, prompt) {
|
|
984
|
+
const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
|
|
985
|
+
if (loopRow === undefined)
|
|
986
|
+
return null;
|
|
987
|
+
const loopId = loopRow.id;
|
|
988
|
+
const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
|
|
989
|
+
const turnSeq = turnRow?.next ?? 1;
|
|
990
|
+
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
991
|
+
if (sessionRow === undefined)
|
|
992
|
+
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
993
|
+
const pathname = `prompt/${loopId}/${turnSeq}`;
|
|
994
|
+
const ctx = {
|
|
995
|
+
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
996
|
+
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
997
|
+
writer: "system",
|
|
998
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
999
|
+
streamEventNotify: this.#streamEventNotify,
|
|
1000
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
1001
|
+
};
|
|
1002
|
+
const entry = {
|
|
1003
|
+
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
1004
|
+
tags: [],
|
|
1005
|
+
};
|
|
1006
|
+
await writeEntry(pathname, entry, ctx, "plurnk");
|
|
1007
|
+
return { loopId, turnSeq };
|
|
1008
|
+
}
|
|
757
1009
|
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
758
1010
|
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
759
1011
|
// (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
|
|
@@ -824,8 +1076,8 @@ export default class Engine {
|
|
|
824
1076
|
});
|
|
825
1077
|
return { status, outcome, body: resolution.body };
|
|
826
1078
|
}
|
|
827
|
-
//
|
|
828
|
-
//
|
|
1079
|
+
// SPEC §3.6: engine rejects writes whose origin is outside the target
|
|
1080
|
+
// scheme's manifest.writableBy.
|
|
829
1081
|
// - Read-side ops (READ, FIND, SHOW, HIDE) are not gated.
|
|
830
1082
|
// - SEND broadcast (path=null) has no target scheme; not gated.
|
|
831
1083
|
// - COPY: dst scheme writableBy applies.
|
|
@@ -834,15 +1086,20 @@ export default class Engine {
|
|
|
834
1086
|
#checkWritable(statement, origin) {
|
|
835
1087
|
if (!MUTATING_OPS.has(statement.op))
|
|
836
1088
|
return null;
|
|
837
|
-
if (statement.op === "SEND" && statement.
|
|
1089
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
838
1090
|
return null;
|
|
1091
|
+
// EXEC's target slot is `cwd`, not a scheme address. The op's
|
|
1092
|
+
// authority always belongs to the exec scheme regardless of cwd.
|
|
1093
|
+
if (statement.op === "EXEC") {
|
|
1094
|
+
return this.#denyIfDisallowed("exec", origin);
|
|
1095
|
+
}
|
|
839
1096
|
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
840
1097
|
const dstScheme = this.#schemeNameOf(statement.body);
|
|
841
1098
|
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
842
1099
|
if (dstDenial !== null)
|
|
843
1100
|
return dstDenial;
|
|
844
1101
|
if (statement.op === "MOVE") {
|
|
845
|
-
const srcScheme = this.#schemeNameOf(statement.
|
|
1102
|
+
const srcScheme = this.#schemeNameOf(statement.target);
|
|
846
1103
|
if (srcScheme !== dstScheme) {
|
|
847
1104
|
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
848
1105
|
if (srcDenial !== null)
|
|
@@ -851,7 +1108,7 @@ export default class Engine {
|
|
|
851
1108
|
}
|
|
852
1109
|
return null;
|
|
853
1110
|
}
|
|
854
|
-
const target = this.#schemeNameOf(statement.
|
|
1111
|
+
const target = this.#schemeNameOf(statement.target);
|
|
855
1112
|
return this.#denyIfDisallowed(target, origin);
|
|
856
1113
|
}
|
|
857
1114
|
#denyIfDisallowed(schemeName, origin) {
|
|
@@ -874,15 +1131,15 @@ export default class Engine {
|
|
|
874
1131
|
// set returns 403 — action-entry-as-outcome carries the rejection.
|
|
875
1132
|
async #checkFlagsGate(statement, loopId) {
|
|
876
1133
|
// Broadcast SEND has no scheme to gate.
|
|
877
|
-
if (statement.op === "SEND" && statement.
|
|
1134
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
878
1135
|
return null;
|
|
879
1136
|
const flags = await this.#loadLoopFlags(loopId);
|
|
880
1137
|
// Fast path: default flags gate nothing. (yolo never gates.)
|
|
881
1138
|
if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
|
|
882
1139
|
return null;
|
|
883
1140
|
const active = this.#schemes.resolveForLoop(flags);
|
|
884
|
-
const check = (
|
|
885
|
-
const scheme = this.#schemeNameOf(
|
|
1141
|
+
const check = (target) => {
|
|
1142
|
+
const scheme = this.#schemeNameOf(target);
|
|
886
1143
|
if (scheme === null)
|
|
887
1144
|
return null;
|
|
888
1145
|
if (active.has(scheme))
|
|
@@ -890,14 +1147,14 @@ export default class Engine {
|
|
|
890
1147
|
return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
|
|
891
1148
|
};
|
|
892
1149
|
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
893
|
-
return check(statement.
|
|
1150
|
+
return check(statement.target) ?? check(statement.body);
|
|
894
1151
|
}
|
|
895
|
-
return check(statement.
|
|
1152
|
+
return check(statement.target);
|
|
896
1153
|
}
|
|
897
1154
|
async #handleCopy(statement, ctx) {
|
|
898
1155
|
if (statement.op !== "COPY")
|
|
899
1156
|
throw new Error("unreachable");
|
|
900
|
-
const srcPath = statement.
|
|
1157
|
+
const srcPath = statement.target;
|
|
901
1158
|
const dstPath = statement.body;
|
|
902
1159
|
if (srcPath === null)
|
|
903
1160
|
return { status: 400, error: "COPY requires source path" };
|
|
@@ -908,7 +1165,7 @@ export default class Engine {
|
|
|
908
1165
|
async #handleMove(statement, ctx) {
|
|
909
1166
|
if (statement.op !== "MOVE")
|
|
910
1167
|
throw new Error("unreachable");
|
|
911
|
-
const srcPath = statement.
|
|
1168
|
+
const srcPath = statement.target;
|
|
912
1169
|
const dstPath = statement.body;
|
|
913
1170
|
if (srcPath === null)
|
|
914
1171
|
return { status: 400, error: "MOVE requires source path" };
|
|
@@ -966,11 +1223,30 @@ export default class Engine {
|
|
|
966
1223
|
return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
|
|
967
1224
|
}
|
|
968
1225
|
}
|
|
1226
|
+
// `<L>` source range slicing per SPEC.md §16.9 (symmetric with READ
|
|
1227
|
+
// `<L>` — source range, no line-number prefix).
|
|
1228
|
+
// Applied to every channel of the source entry. Binary channels return
|
|
1229
|
+
// 415 since line semantics don't apply.
|
|
1230
|
+
const lineMarker = statement.lineMarker ?? null;
|
|
1231
|
+
let channels = entry.channels;
|
|
1232
|
+
if (lineMarker !== null) {
|
|
1233
|
+
const sliced = {};
|
|
1234
|
+
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
1235
|
+
if (isBinaryMimetype(channelData.mimetype)) {
|
|
1236
|
+
return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
|
|
1237
|
+
}
|
|
1238
|
+
const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
|
|
1239
|
+
if (r.status !== 200)
|
|
1240
|
+
return { status: r.status, error: r.error };
|
|
1241
|
+
sliced[channelName] = { ...channelData, content: r.text ?? "" };
|
|
1242
|
+
}
|
|
1243
|
+
channels = sliced;
|
|
1244
|
+
}
|
|
969
1245
|
// Tag resolution: signal = replace; absent/empty = carry from source
|
|
970
1246
|
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
971
1247
|
? statement.signal
|
|
972
1248
|
: entry.tags;
|
|
973
|
-
const writeResult = await dstHandler.writeEntry(dstPathname, { channels
|
|
1249
|
+
const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
|
|
974
1250
|
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
975
1251
|
}
|
|
976
1252
|
async #handleSendBroadcast(statement, loopId) {
|
|
@@ -1007,7 +1283,7 @@ export default class Engine {
|
|
|
1007
1283
|
return "file"; // local (bare) → file
|
|
1008
1284
|
}
|
|
1009
1285
|
async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
|
|
1010
|
-
const target = this.#extractTarget(statement.
|
|
1286
|
+
const target = this.#extractTarget(statement.target);
|
|
1011
1287
|
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
1012
1288
|
? JSON.stringify(statement.lineMarker)
|
|
1013
1289
|
: null;
|
|
@@ -1018,9 +1294,36 @@ export default class Engine {
|
|
|
1018
1294
|
// consumes on accept. All other statuses are terminal — state =
|
|
1019
1295
|
// 'resolved' for the common case.
|
|
1020
1296
|
const isProposed = result.status === 202;
|
|
1021
|
-
|
|
1022
|
-
?
|
|
1023
|
-
:
|
|
1297
|
+
let attrsObj = (result.attrs !== undefined && result.attrs !== null)
|
|
1298
|
+
? { ...result.attrs }
|
|
1299
|
+
: {};
|
|
1300
|
+
// EXEC pathname is coordinate-based — the stream entry lives at
|
|
1301
|
+
// exec://<loop_seq>/<turn_seq>/<sequence>/EXEC, mirroring the log
|
|
1302
|
+
// row's own URI at log://<...>/EXEC. The model can correlate the
|
|
1303
|
+
// log meta to its stream output by structural parallelism — no
|
|
1304
|
+
// random slug injection. Coordinate is resolved here because both
|
|
1305
|
+
// log row write and applyResolution need it.
|
|
1306
|
+
if (statement.op === "EXEC") {
|
|
1307
|
+
const seqs = await this.#db.engine_loop_turn_seqs.get({
|
|
1308
|
+
loop_id: loopId, turn_id: turnId,
|
|
1309
|
+
});
|
|
1310
|
+
if (seqs === undefined)
|
|
1311
|
+
throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
|
|
1312
|
+
const coordPathname = `${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/EXEC`;
|
|
1313
|
+
target.scheme = "exec";
|
|
1314
|
+
target.pathname = coordPathname;
|
|
1315
|
+
attrsObj.pathname = coordPathname;
|
|
1316
|
+
// Mutate the in-memory result.attrs too: the dispatch path
|
|
1317
|
+
// hands originalResult.attrs to handler.applyResolution after
|
|
1318
|
+
// proposal accept (see #acceptResolution). Both views — the
|
|
1319
|
+
// stored row AND the in-memory proposal — need the same
|
|
1320
|
+
// coordinate-based pathname so applyResolution writes the
|
|
1321
|
+
// entry at exec://<coord>/EXEC.
|
|
1322
|
+
if (result.attrs !== undefined && result.attrs !== null) {
|
|
1323
|
+
result.attrs.pathname = coordPathname;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const attrs = JSON.stringify(attrsObj);
|
|
1024
1327
|
const row = await this.#db.engine_insert_log_entry.get({
|
|
1025
1328
|
run_id: runId,
|
|
1026
1329
|
loop_id: loopId,
|